Go Programming Language: برنامج تعليمي تمهيدي لـ Golang

نشرت: 2022-03-11

ما هي لغة البرمجة Go؟

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

دروس Golang: توضيح الشعار

اذهب و OOP

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

* Mixins * متاحة من خلال تضمين الهياكل بشكل مجهول ، مما يسمح باستدعاء أساليبها مباشرة على البنية المحتوية (انظر التضمين). يُطلق على عمليات الترويج بهذه الطريقة اسم * إعادة التوجيه * ، وهي ليست مثل التصنيف الفرعي: سيستمر استدعاء الطريقة في البنية الداخلية المضمنة.

لا يعني التضمين أيضًا تعدد الأشكال. بينما قد تحتوي "A" على "B" ، فإن هذا لا يعني أنها "B" - الدوال التي تأخذ "B" لن تأخذ "A" بدلاً من ذلك. لذلك ، نحتاج إلى واجهات ، والتي سنواجهها لفترة وجيزة لاحقًا.

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

لماذا تعلم Golang؟

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

بينما تتمتع Golang بمجموعة ممتازة من الميزات منخفضة المستوى للتعامل مع التزامن ، إلا أن استخدامها بشكل مباشر يمكن أن يصبح معقدًا. في كثير من الحالات ، حفنة من التجريدات التي يمكن إعادة استخدامها على تلك الآليات منخفضة المستوى تجعل الحياة أسهل بكثير.

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

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

  • أنواع وطرق الهيكل
  • اختبارات الوحدة والمعايير
  • Goroutines والقنوات
  • واجهات وكتابة ديناميكية

بناء صندوق بسيط

دعنا نكتب بعض التعليمات البرمجية لتتبع تمويل الشركة الناشئة. يبدأ الصندوق برصيد معين ، ولا يمكن سحب الأموال إلا (سنحدد الإيرادات لاحقًا).

يصور هذا الرسم مثالًا بسيطًا للجوروتين باستخدام لغة البرمجة Go.

إن Go ليست لغة موجهة نحو الهدف: لا توجد فئات أو كائنات أو وراثة. بدلاً من ذلك ، سنعلن عن نوع هيكلي يسمى Fund ، مع وظيفة بسيطة لإنشاء هياكل تمويل جديدة وطريقتين عامتين.

Fund.go

 package funding type Fund struct { // balance is unexported (private), because it's lowercase balance int } // A regular function returning a pointer to a fund func NewFund(initialBalance int) *Fund { // We can return a pointer to a new struct without worrying about // whether it's on the stack or heap: Go figures that out for us. return &Fund{ balance: initialBalance, } } // Methods start with a *receiver*, in this case a Fund pointer func (f *Fund) Balance() int { return f.balance } func (f *Fund) Withdraw(amount int) { f.balance -= amount }

الاختبار مع المعايير

بعد ذلك نحتاج إلى طريقة لاختبار Fund . بدلاً من كتابة برنامج منفصل ، سنستخدم حزمة اختبار Go ، والتي توفر إطارًا لكل من اختبارات الوحدة والمعايير. لا يستحق المنطق البسيط في صندوقنا كتابة اختبارات الوحدة ، ولكن نظرًا لأننا سنتحدث كثيرًا عن الوصول المتزامن Fund لاحقًا ، فإن كتابة معيار مرجعي أمر منطقي.

تشبه المعايير القياسية اختبارات الوحدة ، ولكنها تتضمن حلقة تعمل بنفس الكود عدة مرات (في حالتنا ، fund.Withdraw(1) ). يتيح ذلك لإطار العمل تحديد الوقت الذي يستغرقه كل تكرار ، مع حساب متوسط ​​الفروق العابرة من عمليات البحث على القرص ، وفقدان ذاكرة التخزين المؤقت ، وجدولة العمليات ، وعوامل أخرى غير متوقعة.

يريد إطار عمل الاختبار تشغيل كل معيار لمدة ثانية واحدة على الأقل (افتراضيًا). لضمان ذلك ، سوف يستدعي المعيار عدة مرات ، ويمرر قيمة "عدد التكرارات" المتزايدة في كل مرة (حقل bN ) ، حتى يستغرق التشغيل ثانية على الأقل.

في الوقت الحالي ، سيقوم معيارنا بإيداع بعض الأموال ثم سحبها دولارًا واحدًا في كل مرة.

Fund_test.go

 package funding import "testing" func BenchmarkFund(b *testing.B) { // Add as many dollars as we have iterations this run fund := NewFund(bN) // Burn through them one at a time until they are all gone for i := 0; i < bN; i++ { fund.Withdraw(1) } if fund.Balance() != 0 { b.Error("Balance wasn't zero:", fund.Balance()) } }

لنقم الآن بتشغيله:

 $ go test -bench . funding testing: warning: no tests to run PASS BenchmarkWithdrawals 2000000000 1.69 ns/op ok funding 3.576s

حصل بصوره جيدة. قمنا بتشغيل ملياري (!) تكرار ، وكان الفحص النهائي للميزان صحيحًا. يمكننا تجاهل تحذير "لا توجد اختبارات للتشغيل" ، والذي يشير إلى اختبارات الوحدة التي لم نكتبها (في أمثلة برمجة Go لاحقًا في هذا البرنامج التعليمي ، تم قص التحذير).

الوصول المتزامن في Go

الآن دعنا نجعل المعيار متزامنًا ، لنمذجة مستخدمين مختلفين يقومون بعمليات سحب في نفس الوقت. للقيام بذلك ، سننتج عشرة غروتينات وسنسحب كل واحد منهم عُشر المال.

كيف يمكننا بناء goroutines المتزامنة muiltiple في لغة Go؟

تعتبر Goroutines لبنة البناء الأساسية للتزامن في لغة Go. إنها خيوط خضراء - خيوط خفيفة الوزن يتم إدارتها بواسطة Go runtime ، وليس بواسطة نظام التشغيل. هذا يعني أنه يمكنك تشغيل الآلاف (أو الملايين) منهم دون أي نفقات زائدة كبيرة. يتم إنتاج Goroutines باستخدام الكلمة الأساسية go ، وتبدأ دائمًا بوظيفة (أو استدعاء طريقة):

 // Returns immediately, without waiting for `DoSomething()` to complete go DoSomething()

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

 go func() { // ... do stuff ... }() // Must be a function *call*, so remember the ()

بمجرد أن يتم إنتاج كل جوروتيناتنا ، نحتاج إلى طريقة لانتظارها حتى تنتهي. يمكننا بناء واحدة بأنفسنا باستخدام القنوات ، لكننا لم نواجهها بعد ، لذلك من الممكن تخطيها للأمام.

في الوقت الحالي ، يمكننا فقط استخدام نوع WaitGroup في مكتبة Go القياسية ، والتي توجد لهذا الغرض بالذات. سننشئ واحدة (تسمى " wg ") وندعو wg.Add(1) قبل تفريخ كل عامل ، لتتبع العدد الموجود. ثم سيقوم العمال بالإبلاغ باستخدام wg.Done() . في هذه الأثناء في goroutine الرئيسي ، يمكننا فقط أن نقول wg.Wait() حتى ينتهي كل عامل.

داخل goroutines العامل في المثال التالي ، سنستخدم defer لاستدعاء wg.Done() .

يأخذ defer استدعاء دالة (أو طريقة) ويقوم بتشغيلها مباشرة قبل عودة الوظيفة الحالية ، بعد الانتهاء من كل شيء آخر. هذا مفيد للتنظيف:

 func() { resource.Lock() defer resource.Unlock() // Do stuff with resource }()

بهذه الطريقة يمكننا بسهولة مطابقة Unlock مع Lock الخاص به لسهولة القراءة. والأهم من ذلك ، أن الوظيفة المؤجلة ستعمل حتى إذا كان هناك ذعر في الوظيفة الرئيسية (شيء قد نتعامل معه عبر المحاولة النهائية في لغات أخرى).

أخيرًا ، سيتم تنفيذ الوظائف المؤجلة بالترتيب العكسي الذي تم استدعاؤها إليه ، مما يعني أنه يمكننا القيام بعملية تنظيف متداخلة بشكل جيد (على غرار المصطلح C لـ goto s و label s المتداخلة ، ولكن أكثر إتقانًا):

 func() { db.Connect() defer db.Disconnect() // If Begin panics, only db.Disconnect() will execute transaction.Begin() defer transaction.Close() // From here on, transaction.Close() will run first, // and then db.Disconnect() // ... }()

حسنًا ، مع كل ما قيل ، إليك الإصدار الجديد:

Fund_test.go

 package funding import ( "sync" "testing" ) const WORKERS = 10 func BenchmarkWithdrawals(b *testing.B) { // Skip N = 1 if bN < WORKERS { return } // Add as many dollars as we have iterations this run fund := NewFund(bN) // Casually assume bN divides cleanly dollarsPerFounder := bN / WORKERS // WaitGroup structs don't need to be initialized // (their "zero value" is ready to use). // So, we just declare one and then use it. var wg sync.WaitGroup for i := 0; i < WORKERS; i++ { // Let the waitgroup know we're adding a goroutine wg.Add(1) // Spawn off a founder worker, as a closure go func() { // Mark this worker done when the function finishes defer wg.Done() for i := 0; i < dollarsPerFounder; i++ { fund.Withdraw(1) } }() // Remember to call the closure! } // Wait for all the workers to finish wg.Wait() if fund.Balance() != 0 { b.Error("Balance wasn't zero:", fund.Balance()) } }

يمكننا توقع ما سيحدث هنا. سيقوم جميع العمال بتنفيذ Withdraw فوق بعضهم البعض. داخلها ، f.balance -= amount سوف يقرأ الرصيد ، ويطرح واحدًا ، ثم يعيد كتابته. لكن في بعض الأحيان ، يقرأ عاملان أو أكثر نفس الرصيد ، ويقومان بالطرح نفسه ، وسننتهي بإجمالي خاطئ. حق؟

 $ go test -bench . funding BenchmarkWithdrawals 2000000000 2.01 ns/op ok funding 4.220s

لا ، ما زال يمر. ماذا حدث هنا؟

تذكر أن goroutines عبارة عن خيوط خضراء - تتم إدارتها بواسطة وقت تشغيل Go ، وليس بواسطة نظام التشغيل. يقوم وقت التشغيل بجدولة goroutines عبر العديد من مؤشرات ترابط نظام التشغيل المتوفرة. في وقت كتابة هذا البرنامج التعليمي للغة Go ، لا يحاول Go تخمين عدد سلاسل أنظمة التشغيل التي يجب أن يستخدمها ، وإذا أردنا أكثر من واحد ، فعلينا أن نقول ذلك. أخيرًا ، لا يستبق وقت التشغيل الحالي goroutines - سيستمر goroutine في العمل حتى يفعل شيئًا يشير إلى أنه جاهز للاستراحة (مثل التفاعل مع قناة).

كل هذا يعني أنه على الرغم من أن معيارنا متزامن الآن ، إلا أنه ليس موازيًا . واحد فقط من عمالنا سيرشح نفسه في كل مرة ، وسيستمر حتى ينتهي. يمكننا تغيير هذا من خلال إخبار Go باستخدام المزيد من الخيوط ، عبر متغير بيئة GOMAXPROCS .

 $ GOMAXPROCS=4 go test -bench . funding BenchmarkWithdrawals-4 --- FAIL: BenchmarkWithdrawals-4 account_test.go:39: Balance wasn't zero: 4238 ok funding 0.007s

هذا أفضل. من الواضح أننا الآن نفقد بعض عمليات السحب لدينا ، كما توقعنا.

في مثال برمجة Go هذا ، تكون نتيجة goroutines المتعددة المتوازية غير مواتية.

اجعله خادمًا

في هذه المرحلة لدينا خيارات مختلفة. يمكننا إضافة قفل مزمن أو قفل للقراءة والكتابة حول الصندوق. يمكننا استخدام المقارنة والمبادلة مع رقم الإصدار. يمكننا أن نبذل قصارى جهدنا ونستخدم مخطط CRDT (ربما استبدال حقل balance بقوائم المعاملات لكل عميل ، وحساب الرصيد من هؤلاء).

لكننا لن نفعل أيًا من هذه الأشياء الآن ، لأنها فوضوية أو مخيفة أو كلاهما. بدلاً من ذلك ، سنقرر أن الصندوق يجب أن يكون خادمًا . ما هو الخادم؟ إنه شيء تتحدث إليه. في Go ، تتحدث الأشياء عبر القنوات.

القنوات هي آلية الاتصال الأساسية بين goroutines. يتم إرسال القيم إلى القناة (مع channel <- value ) ، ويمكن استقبالها على الجانب الآخر (مع value = <- channel ). القنوات "آمنة goroutine" ، مما يعني أن أي عدد من goroutines يمكنه الإرسال والاستقبال منها في نفس الوقت.

التخزين المؤقت

يمكن أن تكون قنوات الاتصال المؤقتة بمثابة تحسين للأداء في ظروف معينة ، ولكن يجب استخدامها بحذر شديد (وقياس الأداء!).

ومع ذلك ، هناك استخدامات للقنوات المخزنة لا تتعلق بالاتصال المباشر.

على سبيل المثال ، يُنشئ مصطلح خنق شائع قناة ذات (على سبيل المثال) حجم المخزن المؤقت `10` ثم يرسل عشرة رموز مميزة إليها على الفور. يتم بعد ذلك إنتاج أي عدد من goroutines العامل ، ويتلقى كل منهم رمزًا مميزًا من القناة قبل بدء العمل ، ويرسله مرة أخرى بعد ذلك. بعد ذلك ، مهما كان عدد العمال هناك ، سيعمل عشرة فقط في نفس الوقت.

بشكل افتراضي ، تكون قنوات Go غير مخزنة . هذا يعني أن إرسال قيمة إلى قناة سيتم حظره حتى يصبح goroutine آخر جاهزًا لاستلامه على الفور. يدعم Go أيضًا أحجام المخزن المؤقت الثابتة للقنوات (باستخدام make(chan someType, bufferSize) ). ومع ذلك ، بالنسبة للاستخدام العادي ، عادة ما تكون هذه فكرة سيئة .

تخيل خادم ويب لصندوقنا ، حيث يقوم كل طلب بالسحب. عندما تكون الأمور مشغولة للغاية ، لن يتمكن FundServer من مواكبة الأمر ، وسيبدأ حظر الطلبات التي تحاول الإرسال إلى قناة الأوامر الخاصة به والانتظار. في هذه المرحلة ، يمكننا فرض الحد الأقصى لعدد الطلبات في الخادم ، وإرجاع رمز خطأ معقول (مثل 503 Service Unavailable ) إلى العملاء الذين يتجاوزون هذا الحد. هذا هو أفضل سلوك ممكن عندما يكون الخادم محملاً بشكل زائد.

ستؤدي إضافة التخزين المؤقت إلى قنواتنا إلى جعل هذا السلوك أقل حتمية. يمكن أن ينتهي بنا الأمر بسهولة مع طوابير طويلة من الأوامر غير المعالجة بناءً على المعلومات التي رآها العميل قبل ذلك بكثير (وربما للطلبات التي انتهت مهلتها منذ ذلك الحين في المنبع). وينطبق الشيء نفسه في العديد من المواقف الأخرى ، مثل الضغط العكسي على TCP عندما لا يستطيع المتلقي مواكبة المرسل.

على أي حال ، بالنسبة لمثال Go الخاص بنا ، سنلتزم بالسلوك الافتراضي غير المحدود.

سنستخدم قناة لإرسال أوامر إلى FundServer بنا. سيرسل كل عامل معياري أوامر إلى القناة ، لكن الخادم فقط هو الذي سيستقبلها.

يمكننا تحويل نوع الصندوق الخاص بنا إلى تطبيق خادم مباشرةً ، ولكن سيكون ذلك فوضويًا - سنقوم بخلط التعامل مع التزامن ومنطق العمل. بدلاً من ذلك ، سنترك نوع الصندوق كما هو تمامًا ، ونجعل FundServer منفصلاً حوله.

مثل أي خادم ، سيكون للغلاف حلقة رئيسية ينتظر فيها الأوامر ، ويستجيب لكل منها بدوره. هناك تفصيل آخر نحتاج إلى معالجته هنا: نوع الأوامر.

رسم تخطيطي للصندوق المستخدم كخادم في هذا البرنامج التعليمي لبرمجة Go.

المؤشرات

كان بإمكاننا جعل قناة أوامرنا تأخذ * مؤشرات * إلى الأوامر (`chan * TransactionCommand`). لماذا لم نفعل؟

يعد تمرير المؤشرات بين goroutines محفوفًا بالمخاطر ، لأن أيًا من goroutine قد يعدله. غالبًا ما يكون أقل كفاءة ، لأن goroutine الآخر قد يعمل على نواة مختلفة لوحدة المعالجة المركزية (مما يعني المزيد من إبطال ذاكرة التخزين المؤقت).

كلما كان ذلك ممكنًا ، تفضل تمرير قيم بسيطة.

في القسم التالي أدناه ، سنرسل عدة أوامر مختلفة ، لكل منها نوع البنية الخاص بها. نريد أن تقبل قناة أوامر الخادم أيًا منها. في لغة OOP ، يمكننا القيام بذلك عبر تعدد الأشكال: اجعل القناة تأخذ فئة فائقة ، والتي كانت أنواع الأوامر الفردية منها عبارة عن فئات فرعية. في Go ، نستخدم واجهات بدلاً من ذلك.

الواجهة عبارة عن مجموعة من تواقيع الأسلوب. يمكن التعامل مع أي نوع يقوم بتنفيذ كل هذه الأساليب على أنه تلك الواجهة (دون التصريح بذلك). في أول تشغيل لنا ، لن تكشف هياكل الأوامر عن أية طرق ، لذلك سنستخدم الواجهة الفارغة ، interface{} . نظرًا لعدم وجود متطلبات لها ، فإن أي قيمة (بما في ذلك القيم الأولية مثل الأعداد الصحيحة) تفي بالواجهة الفارغة. هذا ليس مثاليًا - نريد فقط قبول هياكل الأوامر - لكننا سنعود إليها لاحقًا.

في الوقت الحالي ، دعنا نبدأ مع السقالات لخادم Go الخاص بنا:

server.go

 package funding type FundServer struct { Commands chan interface{} fund Fund } func NewFundServer(initialBalance int) *FundServer { server := &FundServer{ // make() creates builtins like channels, maps, and slices Commands: make(chan interface{}), fund: NewFund(initialBalance), } // Spawn off the server's main loop immediately go server.loop() return server } func (s *FundServer) loop() { // The built-in "range" clause can iterate over channels, // amongst other things for command := range s.Commands { // Handle the command } }

لنقم الآن بإضافة نوعين من أنواع بنية Golang للأوامر:

 type WithdrawCommand struct { Amount int } type BalanceCommand struct { Response chan int }

يحتوي أمر WithdrawCommand فقط على المبلغ المراد سحبه. لا يوجد رد. لدى BalanceCommand رد فعل ، لذا فهو يتضمن قناة لإرسالها عليها. هذا يضمن أن الردود ستذهب دائمًا إلى المكان الصحيح ، حتى لو قرر صندوقنا لاحقًا الرد خارج النظام.

الآن يمكننا كتابة الحلقة الرئيسية للخادم:

 func (s *FundServer) loop() { for command := range s.Commands { // command is just an interface{}, but we can check its real type switch command.(type) { case WithdrawCommand: // And then use a "type assertion" to convert it withdrawal := command.(WithdrawCommand) s.fund.Withdraw(withdrawal.Amount) case BalanceCommand: getBalance := command.(BalanceCommand) balance := s.fund.Balance() getBalance.Response <- balance default: panic(fmt.Sprintf("Unrecognized command: %v", command)) } } }

همم. هذا نوع من القبيح. نقوم بتبديل نوع الأمر ، باستخدام تأكيدات الكتابة ، وربما نتعطل. دعونا نمضي قدمًا على أي حال ونقوم بتحديث المعيار لاستخدام الخادم.

 func BenchmarkWithdrawals(b *testing.B) { // ... server := NewFundServer(bN) // ... // Spawn off the workers for i := 0; i < WORKERS; i++ { wg.Add(1) go func() { defer wg.Done() for i := 0; i < dollarsPerFounder; i++ { server.Commands <- WithdrawCommand{ Amount: 1 } } }() } // ... balanceResponseChan := make(chan int) server.Commands <- BalanceCommand{ Response: balanceResponseChan } balance := <- balanceResponseChan if balance != 0 { b.Error("Balance wasn't zero:", balance) } }

كان هذا نوعًا من القبيح أيضًا ، خاصةً عندما فحصنا الميزان. لا تهتم. دعونا نحاول ذلك:

 $ GOMAXPROCS=4 go test -bench . funding BenchmarkWithdrawals-4 5000000 465 ns/op ok funding 2.822s

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

اجعلها خدمة

الخادم هو شيء تتحدث إليه. ما هي الخدمة؟ الخدمة هي شيء تتحدث إليه باستخدام واجهة برمجة التطبيقات . بدلاً من جعل كود العميل يعمل مع قناة الأوامر مباشرة ، سنجعل القناة غير مُصدرة (خاصة) ونلتف الأوامر المتاحة في الوظائف.

 type FundServer struct { commands chan interface{} // Lowercase name, unexported // ... } func (s *FundServer) Balance() int { responseChan := make(chan int) s.commands <- BalanceCommand{ Response: responseChan } return <- responseChan } func (s *FundServer) Withdraw(amount int) { s.commands <- WithdrawCommand{ Amount: amount } }

الآن يمكن أن يقول مقياس الأداء لدينا فقط الخادم ، server.Withdraw(1) balance := server.Balance() ، وهناك فرصة أقل لإرسال أوامر غير صالحة عن طريق الخطأ أو نسيان قراءة الردود.

إليك ما قد يبدو عليه استخدام الصندوق كخدمة في نموذج برنامج اللغة Go.

لا يزال هناك الكثير من النماذج المعيارية الإضافية للأوامر ، لكننا سنعود إلى ذلك لاحقًا.

المعاملات

في النهاية ، ينفد المال دائمًا. دعنا نتفق على أننا سنتوقف عن الانسحاب عندما ينخفض ​​صندوقنا إلى آخر عشرة دولارات ، وننفق هذه الأموال على بيتزا مشتركة للاحتفال أو التعايش. سيعكس معيارنا هذا:

 // Spawn off the workers for i := 0; i < WORKERS; i++ { wg.Add(1) go func() { defer wg.Done() for i := 0; i < dollarsPerFounder; i++ { // Stop when we're down to pizza money if server.Balance() <= 10 { break } server.Withdraw(1) } }() } // ... balance := server.Balance() if balance != 10 { b.Error("Balance wasn't ten dollars:", balance) }

هذه المرة يمكننا حقًا التنبؤ بالنتيجة.

 $ GOMAXPROCS=4 go test -bench . funding BenchmarkWithdrawals-4 --- FAIL: BenchmarkWithdrawals-4 fund_test.go:43: Balance wasn't ten dollars: 6 ok funding 0.009s

لقد عدنا إلى حيث بدأنا - يمكن للعديد من العاملين قراءة الميزان مرة واحدة ، ثم يقوموا جميعًا بتحديثه. للتعامل مع هذا يمكننا إضافة بعض المنطق في الصندوق نفسه ، مثل خاصية minimumBalance ، أو إضافة أمر آخر يسمى WithdrawIfOverXDollars . كلاهما أفكار رهيبة. اتفاقيتنا فيما بيننا وليست ملكية للصندوق. يجب علينا الاحتفاظ بها في منطق التطبيق.

ما نحتاجه حقًا هو المعاملات ، بنفس معنى معاملات قاعدة البيانات. نظرًا لأن خدمتنا تنفذ أمرًا واحدًا فقط في كل مرة ، فهذا أمر سهل للغاية. سنضيف أمر Transact الذي يحتوي على رد اتصال (إغلاق). سيقوم الخادم بتنفيذ رد الاتصال هذا داخل goroutine الخاص به ، مروراً Fund الأولي. يمكن لمعاودة الاتصال بعد ذلك القيام بكل ما يحلو له بأمان مع Fund .

الإشارات والأخطاء

في المثال التالي نقوم بعمل شيئين صغيرين بشكل خاطئ.

أولاً ، نحن نستخدم قناة "Done" كإشارة لإعلام رمز الاتصال عند انتهاء معاملته. لا بأس ، ولكن لماذا نوع القناة "منطقي"؟ سنرسل "صواب" إليه فقط لتعني "تم" (ما الذي يعنيه إرسال "خطأ"؟). ما نريده حقًا هو قيمة الحالة الواحدة (قيمة ليس لها قيمة؟). في Go ، يمكننا القيام بذلك باستخدام نوع البنية الفارغ: `Struct {}`. هذا أيضًا له ميزة استخدام ذاكرة أقل. في المثال سنلتزم بـ "منطقية" حتى لا تبدو مخيفة للغاية.

ثانيًا ، رد الاتصال الخاص بالمعاملة لا يقوم بإرجاع أي شيء. كما سنرى بعد قليل ، يمكننا الحصول على قيم من رد الاتصال في كود الاتصال باستخدام حيل النطاق. ومع ذلك ، من المفترض أن تفشل المعاملات في نظام حقيقي في بعض الأحيان ، لذا فإن اصطلاح Go هو جعل المعاملة تُرجع "خطأ" (ثم تحقق مما إذا كانت "لا شيء" في كود الاستدعاء).

نحن لا نقوم بذلك أيضًا في الوقت الحالي ، نظرًا لأنه ليس لدينا أي أخطاء يمكن إنشاؤها.
 // Typedef the callback for readability type Transactor func(fund *Fund) // Add a new command type with a callback and a semaphore channel type TransactionCommand struct { Transactor Transactor Done chan bool } // ... // Wrap it up neatly in an API method, like the other commands func (s *FundServer) Transact(transactor Transactor) { command := TransactionCommand{ Transactor: transactor, Done: make(chan bool), } s.commands <- command <- command.Done } // ... func (s *FundServer) loop() { for command := range s.commands { switch command.(type) { // ... case TransactionCommand: transaction := command.(TransactionCommand) transaction.Transactor(s.fund) transaction.Done <- true // ... } } }

لا تقوم عمليات رد نداء المعاملات الخاصة بنا بإرجاع أي شيء بشكل مباشر ، ولكن لغة Go تجعل من السهل الحصول على القيم من الإغلاق مباشرة ، لذلك سنفعل ذلك في المعيار لتعيين علامة pizzaTime عندما ينخفض ​​المال:

 pizzaTime := false for i := 0; i < dollarsPerFounder; i++ { server.Transact(func(fund *Fund) { if fund.Balance() <= 10 { // Set it in the outside scope pizzaTime = true return } fund.Withdraw(1) }) if pizzaTime { break } }

وتحقق من أنها تعمل:

 $ GOMAXPROCS=4 go test -bench . funding BenchmarkWithdrawals-4 5000000 775 ns/op ok funding 4.637s

لا شيء سوى المعاملات

ربما تكون قد اكتشفت فرصة لتنظيف بعض الأشياء أكثر الآن. نظرًا لأن لدينا أمر Transact عام ، لم نعد بحاجة إلى WithdrawCommand أو BalanceCommand بعد الآن. سنعيد كتابتها من حيث المعاملات:

 func (s *FundServer) Balance() int { var balance int s.Transact(func(f *Fund) { balance = f.Balance() }) return balance } func (s *FundServer) Withdraw(amount int) { s.Transact(func (f *Fund) { f.Withdraw(amount) }) }

الآن الأمر الوحيد الذي يتخذه الخادم هو TransactionCommand ، لذا يمكننا إزالة الفوضى في تنفيذ interface{} ، وجعلها تقبل أوامر المعاملة فقط:

 type FundServer struct { commands chan TransactionCommand fund *Fund } func (s *FundServer) loop() { for transaction := range s.commands { // Now we don't need any type-switch mess transaction.Transactor(s.fund) transaction.Done <- true } }

أفضل بكثير.

هناك خطوة أخيرة يمكننا اتخاذها هنا. بصرف النظر عن وظائف الراحة Balance Withdraw ، لم يعد تنفيذ الخدمة مرتبطًا Fund . بدلاً من إدارة Fund ، يمكنه إدارة interface{} واستخدامه في إتمام أي شيء . ومع ذلك ، يجب أن تقوم كل عملية رد اتصال عندئذٍ بتحويل interface{} مرة أخرى إلى قيمة حقيقية:

 type Transactor func(interface{}) server.Transact(func(managedValue interface{}) { fund := managedValue.(*Fund) // Do stuff with fund ... })

هذا قبيح وعرضة للخطأ. ما نريده حقًا هو أدوية تجميع الوقت ، حتى نتمكن من "وضع نموذج" لخادم لنوع معين (مثل *Fund ).

لسوء الحظ ، لا يدعم Go الأدوية الجنسية - حتى الآن. من المتوقع أن يصل في النهاية ، بمجرد أن يكتشف شخص ما بعض القواعد المنطقية والدلالات لذلك. في غضون ذلك ، غالبًا ما يزيل التصميم الدقيق للواجهة الحاجة إلى الأدوية الجنيسة ، وعندما لا يفعلون ذلك يمكننا تجاوز تأكيدات النوع (التي يتم التحقق منها في وقت التشغيل).

هل انتهينا؟

نعم.

حسنًا ، حسنًا ، لا.

على سبيل المثال:

  • الذعر في الصفقة سيقتل الخدمة بأكملها.

  • لا توجد مهلات. المعاملة التي لا تعود أبدًا ستمنع الخدمة إلى الأبد.

  • إذا قام صندوقنا بتنمية بعض الحقول الجديدة وتعطلت إحدى المعاملات في منتصف الطريق من خلال تحديثها ، فستكون لدينا حالة غير متسقة.

  • المعاملات قادرة على تسريب كائن Fund المُدار ، وهذا ليس جيدًا.

  • لا توجد طريقة معقولة لإجراء المعاملات عبر صناديق متعددة (مثل السحب من أحدها والإيداع في صندوق آخر). لا يمكننا تداخل معاملاتنا فقط لأنها ستسمح بحدوث مأزق.

  • يتطلب إجراء معاملة غير متزامنة الآن goroutine جديدًا والكثير من العبث. فيما يتعلق بذلك ، ربما نريد أن نتمكن من قراءة أحدث حالة Fund من مكان آخر أثناء إجراء معاملة طويلة الأمد.

في البرنامج التعليمي التالي للغة البرمجة Go ، سننظر في بعض الطرق لمعالجة هذه المشكلات.

ذات صلة: منطق منظم جيدًا: برنامج تعليمي لـ Golang OOP