توضح المقالة كيفية تنفيذ معالجة الأخطاء والتسجيل على أساس مبدأ "Made and نسيت" في Go. تم تصميم هذه الطريقة لخدمة microservices on Go ، التي تعمل في حاوية Docker وصُممت وفقًا لمبادئ الهندسة المعمارية النظيفة.
هذه المقالة هي نسخة مفصلة لتقرير من اجتماع جو الأخير في قازان . إذا كنت مهتمًا بـ Go وتعيش في قازان أو Innopolis أو Yoshkar-Ola الجميلة أو في مدينة أخرى قريبة ، فيجب عليك زيارة صفحة المجتمع: golangkazan.imtqy.com .
في الاجتماع ، أظهر فريقنا في تقريرين كيف نقوم بتطوير الخدمات المصغرة على الذهاب - ما هي المبادئ التي نتبعها وكيف يمكننا تبسيط حياتنا. تركز هذه المقالة على مفهومنا لمعالجة الأخطاء ، والذي نمده الآن ليشمل جميع خدماتنا المصغرة الجديدة.
اتفاقات هيكل Microservice
قبل التطرق إلى قواعد معالجة الأخطاء ، يجدر تحديد القيود التي نلاحظها عند التصميم والترميز. للقيام بذلك ، تجدر الإشارة إلى شكل خدماتنا الميكروية.
بادئ ذي بدء ، نحن نحترم الهندسة المعمارية النظيفة. نقسم الشفرة إلى ثلاثة مستويات ونلاحظ قاعدة التبعية: الحزم في مستوى أعمق مستقلة عن الحزم الخارجية ولا توجد تبعيات دورية. لحسن الحظ ، فإن التبعيات المباشرة للروبن المستديرة للحزم محظورة في Go. لا تزال تظهر التبعيات غير المباشرة من خلال استعارة المصطلحات أو الافتراضات المتعلقة بالسلوك أو الإيحاء بنوع ما ، ويجب تجنبها.
هكذا تبدو مستوياتنا:
- يحتوي مستوى المجال على قواعد منطق الأعمال التي تمليها مجال الموضوع.
- في بعض الأحيان نقوم به دون مجال إذا كانت المهمة بسيطة
- القاعدة: يعتمد الرمز على مستوى المجال فقط على إمكانيات Go ، ومكتبة Go القياسية والمكتبات المحددة التي تمدد لغة Go
- تحتوي طبقة التطبيق على قواعد منطق الأعمال التي تمليها مهام التطبيق.
- القاعدة: قد يعتمد الرمز على مستوى التطبيق على المجال
- يحتوي مستوى البنية التحتية على رمز البنية الأساسية الذي يربط التطبيق مع العديد من التقنيات للتخزين (MySQL ، Redis) ، النقل (GRPC ، HTTP) ، التفاعل مع البيئة الخارجية ومع الخدمات الأخرى
- القاعدة: قد يعتمد الرمز على مستوى البنية الأساسية على المجال والتطبيق
- القاعدة: تقنية واحدة فقط لكل حزمة Go
- تقوم الحزمة الرئيسية بإنشاء جميع الكائنات - "مفردة العمر" ، وتربطها معًا وتطلق خطوطًا طويلة العمر - على سبيل المثال ، تبدأ بمعالجة طلبات HTTP من المنفذ 8081
هذه هي الطريقة التي تبدو بها شجرة دليل microservice (الجزء الذي يوجد به رمز Go):

لكل سياق من سياقات التطبيق (الوحدات النمطية) ، تبدو بنية الحزمة كما يلي:
- تعلن حزمة التطبيق عن واجهة الخدمة ، والتي تحتوي على جميع الإجراءات الممكنة في هذا المستوى ، وتنفيذ واجهة هيكل الخدمة وخدمة
func NewService(...) Service
- يتم تحقيق عزل العمل مع قاعدة البيانات بسبب حقيقة أن حزمة المجال أو التطبيق تعلن عن واجهة مستودع التخزين ، والتي يتم تنفيذها على مستوى البنية التحتية في الحزمة مع الاسم المرئي "mysql"
- يقع رمز النقل في حزمة
infrastructure/transport
- نحن نستخدم GRPC ، لذلك يتم إنشاء كعب روتين الخادم من ملف proto (مثل واجهة الخادم وهياكل الاستجابة / الطلب وكل رمز تفاعل العميل)
كل هذا مبين في الرسم البياني:

خطأ في معالجة المبادئ
كل شيء بسيط هنا:
- نعتقد أن الأخطاء والفزع يحدث عند معالجة الطلبات إلى API - مما يعني أن الخطأ أو الذعر يجب أن يؤثر على طلب واحد فقط
- نعتقد أن السجلات مطلوبة فقط لتحليل الحوادث (وهناك مصحح أخطاء لتصحيح الأخطاء) ، وبالتالي ، يتم تلقي معلومات حول الطلبات في السجل ، وقبل كل شيء ، أخطاء غير متوقعة عند معالجة الطلبات
- نعتقد أن البنية الأساسية بأكملها مصممة لمعالجة السجلات (على سبيل المثال ، بناءً على ELK) - وتلعب الخدمات المصغرة دورًا سلبيًا فيها ، حيث تقوم بكتابة سجلات إلى stderr
لن نركز على الذعر: لا تنسَ التعامل مع الذعر في كل مجموعة goroutine وأثناء معالجة كل طلب وكل رسالة وكل مهمة غير متزامنة يطلقها الطلب. دائما تقريبا ، يمكن أن يتحول الذعر إلى خطأ لمنع التطبيق بأكمله من الانتهاء.
المصطلح الحارس الأخطاء
على مستوى منطق الأعمال ، تتم معالجة الأخطاء المتوقعة فقط المعرفة بواسطة قواعد العمل. ستساعدك أخطاء Sentinel في تحديد هذه الأخطاء - نستخدم هذا المصطلح بدلاً من كتابة أنواع البيانات الخاصة بنا للأخطاء. مثال:
package app import "errors" var ErrNoCake = errors.New("no cake found")
يتم الإعلان عن متغير عالمي هنا ، والذي ، باتفاق السيد المحترم ، يجب ألا نتغير في أي مكان. إذا لم تعجبك المتغيرات العالمية واستخدمت linter للكشف عنها ، فيمكنك الحصول على بعض الثوابت ، كما يقترح ديف تشيني في منشور أخطاء ثابت :
package app type Error string func (e Error) Error() string { return string(e) } const ErrNoCake = Error("no cake found")
إذا كنت تحب هذه ConstError
، فقد ترغب في إضافة نوع ConstError
إلى مكتبة لغات Go الخاصة ConstError
.
تكوين الأخطاء
الميزة الرئيسية لأخطاء الحارس هي القدرة على تكوين الأخطاء بسهولة. على وجه الخصوص ، عند إنشاء خطأ أو تلقي خطأ من الخارج ، سيكون من الجميل إضافة stacktrace إليه. لهذه الأغراض ، هناك حلان شائعان.
- حزمة xerrors ، والتي سيتم تضمينها في Go 1.13 في المكتبة القياسية كتجربة
- حزمة github.com/pkg/errors بواسطة ديف تشيني
- الحزمة مجمدة ولا تتوسع ، لكنها مع ذلك جيدة
لا يزال فريقنا يستخدم errors.WithStack
وظائف errors.WithStack
(عندما لا يكون لدينا شيء نضيفه ، باستثناء stacktrace) أو errors.Wrap
(عندما يكون لدينا شيء نقوله حول هذا الخطأ). كلتا الدالتين تقبلان خطأً في المدخلات وتعيدان خطأً جديداً ، لكن باستخدام stacktrace. مثال من طبقة البنية التحتية:
package mysql import "github.com/pkg/errors" func (r *repository) FindOne(...) { row := r.client.QueryRow(sql, params...) switch err := row.Scan(...) { case sql.ErrNoRows:
نوصي بأن يتم تغليف كل خطأ مرة واحدة فقط. من السهل القيام بذلك إذا اتبعت القواعد:
- يتم التفاف أي أخطاء خارجية مرة واحدة في واحدة من حزم البنية التحتية
- أي أخطاء تنشأ عن قواعد منطق الأعمال يتم استكمالها بواسطة stacktrace في وقت الإنشاء
السبب الجذري للخطأ
جميع الأخطاء تنقسم بشكل متوقع إلى غير متوقع. للتعامل مع الخطأ المتوقع ، تحتاج إلى التخلص من آثار التكوين. تحتوي حزم xerrors و github.com/pkg/errors على كل ما تحتاجه: على وجه الخصوص ، تحتوي حزمة الأخطاء على errors.Cause
، والتي تُرجع السبب الجذري للخطأ. هذه الوظيفة في حلقة ، واحدة تلو الأخرى ، باسترداد الأخطاء السابقة بينما الخطأ المستخرج التالي لديه أسلوب Cause() error
.
مثال على أننا نستخلص السبب الجذري ونقارنه مباشرة مع خطأ الحارس:
func (s *service) SaveCake(...) error { state, err := s.repo.FindOne(...) if errors.Cause(err) == ErrNoCake { err = nil
خطأ في معالجة في تأجيل
ربما كنت تستخدم linter ، مما يجعلك تحقق يدويًا من جميع الأخطاء. في هذه الحالة ، من المحتمل أن تشعر بالغضب عندما يطلب منك linter التحقق من الأخطاء باستخدام طرق .Close()
وغيرها من الطرق التي defer
فقط. هل سبق لك أن حاولت معالجة الخطأ في التأجيل بشكل صحيح ، خاصة إذا كان هناك خطأ آخر قبل ذلك؟ لقد جربنا ونحن في عجلة من أمرنا لمشاركة الوصفة.
تخيل أن لدينا كل العمل مع قاعدة البيانات بشكل صارم من خلال المعاملات. وفقًا لقاعدة التبعية ، يجب ألا تعتمد مستويات التطبيق والمجال بشكل مباشر أو غير مباشر على البنية التحتية وتكنولوجيا SQL. هذا يعني أنه على مستوى التطبيق والمجال لا توجد كلمة "معاملة" .
إن أبسط الحلول هو استبدال كلمة "معاملة" بشيء تجريدي ؛ وبالتالي ولدت وحدة نمط العمل. في تطبيقنا ، تستقبل الخدمة في حزمة التطبيق المصنع من خلال واجهة UnitOfWorkFactory ، وخلال كل عملية تقوم بإنشاء كائن UnitOfWork يخفي المعاملة. يسمح لك كائن UnitOfWork بالحصول على مستودع.
المزيد عن UnitOfWorkلفهم استخدام وحدة العمل بشكل أفضل ، ألق نظرة على المخطط:

- يمثل المستودع مجموعة متواصلة مجردة من الكائنات (على سبيل المثال ، مجاميع مستوى المجال) من النوع المحدد
- يخفي UnitOfWork المعاملة ويقوم بإنشاء كائنات مستودع
- UnitOfWorkFactory ببساطة يسمح للخدمة بإنشاء معاملات جديدة دون معرفة أي شيء عن المعاملات.
أليس من المفرط إنشاء معاملة لكل عملية ، حتى في البداية الذرية؟ الأمر متروك لك ؛ نعتقد أن الحفاظ على استقلالية منطق العمل أكثر أهمية من التوفير عند إنشاء معاملة.
هل من الممكن الجمع بين UnitOfWork و Repository؟ إنه ممكن ، لكننا نعتقد أن هذا ينتهك مبدأ المسؤولية الفردية.
هذا ما تبدو عليه الواجهة:
type UnitOfWork interface { Repository() Repository Complete(err *error) }
توفر واجهة UnitOfWork الطريقة الكاملة ، والتي تأخذ معلمة للداخل واحدة: مؤشر إلى واجهة الخطأ. نعم ، إنه المؤشر ، وهو المعلمة الداخلية - في أي حالات أخرى ، سيكون الكود الموجود على جانب الاتصال أكثر تعقيدًا.
مثال العملية مع unitOfWork:
تحذير: يجب التصريح عن الخطأ كقيمة إرجاع مسماة. إذا كنت تستخدم المتغير المحلي يخطئ بدلاً من القيمة المرتجعة المسماة ، فلا يمكنك استخدامها في تأجيل! ولن يكشف لك linter عن ذلك حتى الآن - راجع go-الناقد # 801
func (s *service) CookCake() (err error) { unitOfWork, err := s.unitOfWorkFactory.New() if err != nil { return err } defer unitOfWork.Complete(&err) repo := unitOfWork.Repository() }
حتى يتحقق الانتهاء المعاملات UnitOfWork:
func (u *unitOfWork) Complete(err *error) { if *err == nil {
تقوم دالة mergeErrors
بدمج خطأين ، لكنها تقوم بمعالجة صفر بدون مشاكل بدلاً من خطأ واحد أو كلا الخطأين. في الوقت نفسه ، نعتقد أن كلا الخطأين قد حدث أثناء تنفيذ عملية واحدة في مراحل مختلفة ، والخطأ الأول أكثر أهمية - لذلك ، عندما لا يكون كلا الخطأين صفريًا ، فإننا نقوم بحفظ الخطأ الأول ، ويتم حفظ الرسالة فقط من الخطأ الثاني:
package errors func mergeErrors(err error, nextErr error) error { if err == nil { err = nextErr } else if nextErr != nil { err = errors.Wrap(err, nextErr.Error()) } return err }
ربما يجب عليك إضافة وظيفة mergeErrors
إلى مكتبة الشركة الخاصة بك لـ Go.
تسجيل النظام الفرعي
قائمة مراجعة المقالة : ما كان عليك فعله قبل بدء خدمات micros في prod تنصح:
- سجلات مكتوبة في stderr
- يجب أن تكون السجلات في JSON ، كائن JSON مضغوط واحد لكل سطر
- يجب أن يكون هناك مجموعة قياسية من الحقول:
- الطابع الزمني - وقت الحدث بالميلي ثانية ، ويفضل بتنسيق RFC 3339 (مثال: "1985-04-12T23: 20: 50.52Z")
- المستوى - مستوى الأهمية ، على سبيل المثال ، "معلومات" أو "خطأ"
- app_name - اسم التطبيق
- وغيرها من المجالات
نفضل إضافة حقلين آخرين إلى رسائل الخطأ: "error"
و "stacktrace"
.
هناك العديد من مكتبات تسجيل الجودة للغة Golang ، على سبيل المثال ، sirupsen / logrus ، التي نستخدمها. لكننا لا نستخدم المكتبة مباشرة. بادئ ذي بدء ، في حزمة log
بنا ، نقوم بتقليل واجهة المكتبة الواسعة للغاية إلى واجهة Logger واحدة:
package log type Logger interface { WithField(string, interface{}) Logger WithFields(Fields) Logger Debug(...interface{}) Info(...interface{}) Error(error, ...interface{}) }
إذا أراد المبرمج كتابة السجلات ، فيجب عليه الحصول على واجهة Logger من الخارج ، ويجب أن يتم ذلك على مستوى البنية الأساسية ، وليس التطبيق أو المجال. واجهة المسجل موجزة:
- فهو يقلل من عدد مستويات الخطورة لتصحيح الأخطاء والمعلومات والخطأ ، كما تنصح المقالة ، دعنا نتحدث عن التسجيل.
- يقدم قواعد خاصة لأسلوب Error: الأسلوب يقبل دائمًا كائن خطأ
تسمح مثل هذه الصرامة بتوجيه المبرمجين في الاتجاه الصحيح: إذا أراد شخص ما إجراء تحسين في نظام التسجيل نفسه ، فعليه أن يأخذ ذلك في الاعتبار البنية التحتية بأكملها لجمعها ومعالجتها ، والتي تبدأ فقط في الخدمة المجهرية (وعادة ما تنتهي في مكان ما في Kibana و zabbix).
ومع ذلك ، توجد في حزمة السجل واجهة أخرى تسمح لك بمقاطعة البرنامج عند حدوث خطأ فادح وبالتالي لا يمكن استخدامه إلا في الحزمة الرئيسية:
package log type MainLogger interface { Logger FatalError(error, ...interface{}) }
حزمة Jsonlog
jsonlog
واجهة Logger على حزمة jsonlog
بنا ، والتي تقوم بتكوين مكتبة logrus وتلخص العمل معها. بشكل تخطيطي يبدو مثل هذا:

تسمح لك الحزمة الاحتكارية بتوصيل احتياجات الخدمة المجهرية (التي يتم التعبير عنها بواسطة واجهة log.Logger
) ، وإمكانيات مكتبة logrus ، وميزات البنية الأساسية لديك.
على سبيل المثال ، نستخدم ELK (البحث المرن ، Logstash ، Kibana) ، وبالتالي في حزمة jsonlog:
- تعيين logrus.JSONFormatter تنسيق ل
logrus.JSONFormatter
- في نفس الوقت ، قمنا بتعيين خيار FieldMap ، والذي نحول به حقل
"time"
إلى "@timestamp"
، "@timestamp"
"msg"
إلى "message"
- حدد مستوى السجل
- أضف خطافًا يقوم باستخراج stacktrace من كائن
Error(error, ...interface{})
تم تمريره إلى أسلوب Error(error, ...interface{})
تهيئة microservice المسجل في الوظيفة الرئيسية:
func initLogger(config Config) (log.MainLogger, error) { logLevel, err := jsonlog.ParseLevel(config.LogLevel) if err != nil { return nil, errors.Wrap(err, "failed to parse log level") } return jsonlog.NewLogger(&jsonlog.Config{ Level: logLevel, AppName: "cookingservice" }), nil }
خطأ في معالجة وتسجيل مع الوسيطة
نحن نتحول إلى GRPC في خدماتنا المصغرة أثناء التنقل. ولكن حتى لو كنت تستخدم HTTP API ، فإن المبادئ العامة تناسبك.
بادئ ذي بدء ، يجب أن تحدث معالجة الأخطاء app.Service
على مستوى infrastructure
في الحزمة المسؤولة عن النقل ، لأنه هو الذي يجمع بين معرفة قواعد بروتوكول النقل ومعرفة app.Service
واجهة app.Service
. أذكر شكل علاقة الحزمة:

من الملائم معالجة الأخطاء والحفاظ على السجلات باستخدام نمط الوسيطة (الوسيطة هي اسم نمط الديكور في عالم Golang و Node.js):
حيث لإضافة الوسيطة؟ كم يجب أن يكون هناك؟
هناك خيارات مختلفة لإضافة برنامج Middleware ، يمكنك اختيار:
- يمكنك تزيين واجهة
app.Service
. لكن لا نوصي بالقيام بذلك لأن هذه الواجهة لا تتلقى معلومات طبقة النقل ، مثل عميل IP - باستخدام GRPC ، يمكنك تعليق معالج واحد على جميع الطلبات (بشكل أكثر دقة ، اثنان - أحاديان وبخاران) ، ولكن بعد ذلك سيتم تسجيل جميع أساليب واجهة برمجة التطبيقات بنفس النمط مع مجموعة الحقول نفسها
- باستخدام GRPC ، ينشئ منشئ الشفرة واجهة خادم ندعو فيها إلى
app.Service
طريقة الخدمة - نقوم بتزيين هذه الواجهة لأنها تحتوي على معلومات على مستوى النقل وقدرة على تسجيل طرق API مختلفة بطرق مختلفة
بشكل تخطيطي يبدو مثل هذا:

يمكنك إنشاء Middlewares مختلفة لمعالجة الأخطاء (والذعر) وللتسجيل. يمكنك عبور كل شيء في واحد. سننظر في مثال يتم فيه عبور كل شيء إلى أحد البرامج الوسيطة ، والذي يتم إنشاؤه مثل هذا:
func NewMiddleware(next api.BackendService, logger log.Logger) api.BackendService { server := &errorHandlingMiddleware{ next: next, logger: logger, } return server }
نحصل على واجهة api.BackendService
، api.BackendService
تنفيذ واجهة api.BackendService
.
يتم تطبيق طريقة API التعسفي في Middleware على النحو التالي:
func (m *errorHandlingMiddleware) ListCakes( ctx context.Context, req *api.ListCakesRequest) (*api.ListCakesResponse, error) { start := time.Now() res, err := m.next.ListCakes(ctx, req) m.logCall(start, err, "ListCakes", log.Fields{ "cookIDs": req.CookIDs, }) return res, translateError(err) }
نحن هنا نؤدي ثلاث مهام:
- استدعاء الأسلوب ListCakes للكائن المزين
- نحن
logCall
طريقة logCall
فيها جميع المعلومات المهمة ، بما في ذلك مجموعة من الحقول المحددة بشكل فردي والتي تدخل في السجل - في النهاية ، استبدلنا الخطأ عن طريق استدعاء translateError.
ستتم مناقشة ترجمة الخطأ لاحقًا. ويتم إجراء التسجيل بواسطة طريقة logCall
، والتي تستدعي ببساطة طريقة واجهة Logger الصحيحة:
func (m *errorHandlingMiddleware) logCall(start time.Time, err error, method string, fields log.Fields) { fields["duration"] = fmt.Sprintf("%v", time.Since(start)) fields["method"] = method logger := m.logger.WithFields(fields) if err != nil { logger.Error(err, "call failed") } else { logger.Info("call finished") } }
خطأ في الترجمة
يجب أن نحصل على السبب الجذري للخطأ ونحوله إلى خطأ يمكن فهمه على مستوى النقل وموثق في API لخدمتك.
في GRPC ، الأمر بسيط - استخدم دالة status.Errorf
لإنشاء خطأ برمز الحالة. إذا كان لديك HTTP API (REST API) ، فيمكنك إنشاء نوع الخطأ الخاص بك الذي يجب ألا يكون مستوى التطبيق والمجال على علم به.
في التقريب الأول ، تبدو ترجمة الخطأ كما يلي:
عند التحقق من صحة وسيطات الإدخال ، يمكن للواجهة المزخرفة إرجاع خطأ في الحالة status.Status
نوع الجهاز مع رمز الحالة ، وسوف تفقد النسخة الأولى من translateError رمز الحالة هذا.
دعنا نجعل نسخة محسّنة بالانتقال إلى نوع الواجهة (كتابة البط الحي!):
type statusError interface { GRPCStatus() *status.Status } func isGrpcStatusError(er error) bool { _, ok := err.(statusError) return ok } func translateError(err error) error { if isGrpcStatusError(err) { return err } switch errors.Cause(err) { case app.ErrNoCake: err = status.Errorf(codes.NotFound, err.Error()) default: err = status.Errorf(codes.Internal, err.Error()) } return err }
يتم إنشاء وظيفة translateError
بشكل فردي لكل سياق (وحدة مستقلة) في microservice وترجمة أخطاء منطق الأعمال إلى أخطاء على مستوى النقل.
لتلخيص
نحن نقدم لك العديد من القواعد للتعامل مع الأخطاء والعمل مع السجلات. سواء كنت تريد متابعته أم لا.
- اتبع مبادئ الهندسة المعمارية النظيفة ، لا تخرق بشكل مباشر أو غير مباشر قاعدة التبعيات. يجب أن يعتمد منطق الأعمال على لغة البرمجة فقط وليس على التقنيات الخارجية.
- استخدم حزمة توفر تكوين الأخطاء وإنشاء stacktrace. على سبيل المثال ، "github.com/pkg/errors" أو الحزمة xerrors ، والتي ستصبح قريبًا جزءًا من مكتبة Go القياسية.
- لا تستخدم مكتبات تسجيل الجهات الخارجية في الخدمة المصغرة - قم بإنشاء مكتبتك الخاصة باستخدام حزم السجل و jsonlog ، والتي ستخفي تفاصيل تنفيذ التسجيل
- استخدم نمط Middleware لمعالجة الأخطاء وكتابة السجلات في اتجاه النقل لمستوى البنية الأساسية للبرنامج
لم نذكر هنا أي شيء حول تقنيات تتبع الاستعلام (على سبيل المثال ، OpenTracing) ، ومراقبة المقاييس (على سبيل المثال ، أداء استعلام قاعدة البيانات) وأشياء أخرى مثل التسجيل. أنت نفسك سوف تتعامل مع هذا ، ونحن نؤمن بك.