يوليو 2023
اذهب إلى: ملخص تقني | معالجة الأخطاء | أداء | مقابل بايثون | خاتمة
منذ بضع سنوات كتبت قزم، برنامج Python صغير يكفي لعميل Git لإنشاء مستودع وإضافة بعض الالتزامات ودفع نفسه إلى GitHub.
كنت أرغب في مقارنة الشكل الذي سيبدو عليه في Go، لمعرفة ما إذا كان من المعقول كتابة نصوص برمجية صغيرة في Go – كود سريع وقذر حيث لا يكون الأداء مشكلة كبيرة، وتكون تتبعات المكدس هي كل ما تحتاجه لمعالجة الأخطاء .
النتيجه هي com.gogit، وهو برنامج Go مكون من 400 سطر يمكنه تهيئة المستودع والالتزام والدفع إلى GitHub. إنه مكتوب بلغة Go العادية… باستثناء معالجة الأخطاء، وهو أمر مطول للغاية في لغة Go الاصطلاحية بحيث لا يعمل بشكل جيد في البرمجة النصية (المزيد حول ذلك) أقل).
ملخص تقني
لن أخوض في التفاصيل حول كيفية عمل Git هنا (هناك المزيد في ملفي مقالة قذرة)، ويكفي أن نقول أن نموذج بيانات Git أنيق جدًا. ويستخدم مخزن كائنات بسيطًا يعتمد على الملفات في .git/objects
، حيث يحتوي كل كائن على تجزئة مكونة من 40 حرفًا ويمكن أن يكون يقترف، أ شجرة (قائمة الدليل)، أو أ سائل لزج (ملف ملتزم). هذا كل شيء – رمز جوجيت لكتابة الالتزامات والأشجار والنقط حوالي 50 سطرًا.
لقد قمت بتنفيذ أقل من pygit: فقط init
, commit
، و push
. لا يدعم Gogit حتى الفهرس (منطقة التدريج)، لذا بدلاً من ذلك gogit add
، انت فقط gogit commit
مع قائمة المسارات التي تريد الالتزام بها في كل مرة. مثل رمز بيجيت يظهر أن التعامل مع الفهرس فوضوي. كما أنه غير ضروري، وأردت أن يكون gogit تمرينًا على البساطة.
يقوم Gogit أيضًا بإسقاط الأوامر cat-file
, hash-object
، و diff
– هذه ليست مطلوبة للالتزام والدفع إلى GitHub. لقد استخدمت Git cat-file
أثناء التصحيح، ولكن.
فيما يلي الأوامر التي استخدمتها لإنشاء الريبو والالتزام والدفع إلى GitHub (لاحظ استخدام go run
لتجميع وتنفيذ “البرنامج النصي”):
# Initialise the repo$ go run . init# Make the first commit (other commits are similar)$ export GIT_AUTHOR_NAME='Ben Hoyt'$ export GIT_AUTHOR_EMAIL=benhoyt@gmail.com$ go run . commit -m 'Initial commit' gogit.go go.mod LICENSE.txtcommited 0580a17 to master# Push updates to GitHub$ export GIT_USERNAME=benhoyt$ export GIT_PASSWORD=...$ go run . push https://github.com/benhoyt/gogitupdating remote master from 0000000 to 0580a17 (5 objects)
معالجة الأخطاء
لقد تم إساءة معاملة الإسهاب في معالجة أخطاء Go كثيرًا. إنه أمر بسيط وصريح، ولكن كل استدعاء لوظيفة قد تفشل يتطلب ثلاثة أسطر إضافية من التعليمات البرمجية لمعالجة الخطأ:
mode, err := strconv.ParseInt(modeStr, 8, 64)if err != nil { return err}
لا يعد الأمر أمرًا كبيرًا عند كتابة كود الإنتاج، لأنك ستحتاج بعد ذلك إلى مزيد من التحكم في معالجة الأخطاء على أي حال – أخطاء مغلفة بشكل جيد، أو رسائل يمكن قراءتها بواسطة الإنسان، على سبيل المثال:
mode, err := strconv.ParseInt(modeStr, 8, 64)if err != nil { return fmt.Errorf("mode must be an octal number, not %q", modeStr)}
ولكن في نص بسيط، كل معالجة الأخطاء التي تحتاجها هو إظهار رسالة وطباعة تتبع المكدس والخروج من البرنامج. هذا ما يحدث في لغة بايثون عندما لا تتمكن من اكتشاف الاستثناءات، ومن السهل محاكاتها في لغة Go باستخدام زوج من الوظائف المساعدة:
func check0(err error) { if err != nil { panic(err) }}func check[T any](value T, err error) T { if err != nil { panic(err) } return value}func assert(cond bool, format string, args ...any) { if !cond { panic(fmt.Sprintf(format, args...)) }}
الآن بعد أن أصبح لدى Go أدوية عامة، يمكنك بسهولة تحديد ملف check
الدالة التي ترجع النتيجة. ومع ذلك، لا تزال بحاجة إلى متغيرات بناءً على عدد النتائج التي يتم إرجاعها. عادةً ما يكون هذا صفرًا أو واحدًا، حيث يكون الواحد هو الأكثر شيوعًا، لذلك قمت بتسمية هذا المتغير فقط check
، والنتيجة صفر واحدة check0
. لقد حددت أيضا assert
، والتي تأخذ رسالة منطقية ومنسقة بدلاً من الخطأ.
يسمح لك هؤلاء المساعدون بتحويل هذا الرمز:
func writeTree(paths []string) ([]byte, error) { sort.Strings(paths) // tree object needs paths sorted var buf bytes.Buffer for _, path := range paths { st, err := os.Stat(path) if err != nil { return nil, err } if st.IsDir() { panic("sub-trees not supported") } data, err := os.ReadFile(path) if err != nil { return nil, err } hash, err := hashObject("blob", data) if err != nil { return nil, err } fmt.Fprintf(&buf, "%o %sx00%s", st.Mode().Perm()|0o100000, path, hash) } return hashObject("tree", buf.Bytes())}
فيما يلي، تقليل نص الدالة من 21 إلى 10 سطر، وهو ما يشبه إيجاز بايثون:
func writeTree(paths []string) []byte { sort.Strings(paths) // tree object needs paths sorted var buf bytes.Buffer for _, path := range paths { st := check(os.Stat(path)) assert(!st.IsDir(), "sub-trees not supported") data := check(os.ReadFile(path)) hash := hashObject("blob", data) fmt.Fprintf(&buf, "%o %sx00%s", st.Mode().Perm()|0o100000, path, hash) } return hashObject("tree", buf.Bytes())}
انها ليست مثالية، لأن الكلمة check
يحجب الوظيفة التي تتصل بها قليلاً، ولكنه يجعل كتابة البرامج النصية السريعة والقذرة أجمل كثيرًا.
حتى أنك تحصل على أخطاء “أفضل” من الأخطاء العادية return err
لأن تتبع المكدس يوضح لك بالضبط الوظيفة وسطر التعليمات البرمجية الذي تم تنفيذه:
$ go run . push https://github.com/benhoyt/gogitpanic: Get "https://github.com/benhoyt/gogit/info/refs?service=git-receive-pack": context deadline exceeded (Client.Timeout exceeded while awaiting headers)goroutine 1 [running]:main.check[...](...) /home/ben/h/gogit/gogit.go:94main.getRemoteHash(0x416ad0?, {0x7ffe1f0152d9?, 0x4b87d4?}, {0xc00001c00d, 0x7}, {0xc00001a00d, 0x28}) /home/ben/h/gogit/gogit.go:245 +0x6damain.push({0x7ffe1f0152d9, 0x20}, {0xc00001c00d, 0x7}, {0xc00001a00d, 0x28}) /home/ben/h/gogit/gogit.go:217 +0xd9main.main() /home/ben/h/gogit/gogit.go:73 +0x21eexit status 2
التغيير من return err
ل check
تم تقليل عدد أسطر التعليمات البرمجية من 607 إلى 415، أي انخفاض بنسبة 32%.
إذا كنت ترغب في متابعة هذا النهج بشكل أكبر، فهناك مكتبة كتبها جو تساي وجوش بليشر سنايدر تسمى try
الذي يستخدم recover
للقيام بذلك “بشكل صحيح”. الاشياء! ما زلت آمل أن يكتشف فريق Go طريقة لجعل معالجة الأخطاء أقل تفصيلاً.
أداء
سيكون هذا قسمًا قصيرًا، لأنني لا أهتم بالسرعة في هذا البرنامج، ومن المحتمل أن يكون إصدار Go سريعًا أو أسرع من إصدار Python. يمكن أن يكون Go أسرع بشكل ملحوظ، لكننا نتعامل مع ملفات صغيرة، وفي Python، كل التعليمات البرمجية المثيرة للاهتمام مثل التجزئة والكتابة على القرص مكتوبة بلغة C على أي حال.
يعد استخدام الذاكرة جانبًا آخر من جوانب الأداء. مرة أخرى، نحن نتعامل مع ملفات صغيرة هنا، لذلك لا توجد مشكلة في قراءة كل شيء في الذاكرة. في Python، يمكنك إجراء البث، لكن الأمر ليس سهلاً دائمًا كما هو الحال في Go، نظرًا لما هو مذهل io.Reader
و io.Writer
واجهات.
ومع ذلك، لا يزال الأمر أسهل قليلًا في Go لقراءة كل شيء []byte
أو string
وإجراء العمليات الجراحية عليها، وهذا ما قمت به في gogit. نحن نتحدث عن بضعة كيلو بايت من الذاكرة، وجهازي يحتوي على بضعة جيجا بايت.
مقارنة مع نسخة بايثون
في الوقت الحالي، يتكون Pygit من حوالي 600 سطر من التعليمات البرمجية، وgogit حوالي 400 سطر من التعليمات البرمجية. ومع ذلك، هذا مضلل بعض الشيء، حيث قمت بإزالة العديد من الميزات عند كتابة إصدار Go: لا يوجد دعم لفهرس Git، ولا يوجد cat-file
, hash-object
، أو diff
.
لقد أجريت اختبارًا سريعًا عن طريق إزالة هذه الوظائف من إصدار Python، وانتهى الأمر بـ 360 سطرًا من التعليمات البرمجية. أنا أعتبر 400 في Go مقابل 360 في Python ليس سيئًا – فهو أطول بنسبة 10% فقط. ويتضمن إصدار Go 20 سطرًا من الواردات و20 سطرًا لوظائف الفحص/التأكيد. لذا فهما متطابقان تقريبًا في الحجم!
دعونا نلقي نظرة على بضع وظائف محددة. أولاً، find_object
، والذي يبحث في مخزن كائنات Git للعثور على كائن بالبادئة المحددة. إليك نسخة بايثون:
def find_object(sha1_prefix): obj_dir = os.path.join('.git', 'objects', sha1_prefix[:2]) rest = sha1_prefix[2:] objects = [name for name in os.listdir(obj_dir) if name.startswith(rest)] if not objects: raise ValueError('object {!r} not found'.format(sha1_prefix)) if len(objects) >= 2: raise ValueError('multiple objects ({}) with prefix {!r}'.format( len(objects), sha1_prefix)) return os.path.join(obj_dir, objects[0])
وإليك نسخة Go:
func findObject(hashPrefix string) string { objDir := filepath.Join(".git/objects", hashPrefix[:2]) rest := hashPrefix[2:] entries, _ := os.ReadDir(objDir) var matches []string for _, entry := range entries { if strings.HasPrefix(entry.Name(), rest) { matches = append(matches, entry.Name()) } } assert(len(matches) > 0, "object %q not found", hashPrefix) assert(len(matches) == 1, "multiple objects with prefix %q", hashPrefix) return filepath.Join(objDir, matches[0])}
هناك الكثير من الأشياء المتشابهة، على سبيل المثال os.path.join
ضد filepath.Join
, os.listdir
ضد os.ReadDir
، وما إلى ذلك وهلم جرا. لكن لاحظ أن فهم القائمة في بايثون – سطر واحد – يتكون من خمسة أسطر for
حلقة في الذهاب. أفتقد فهم القائمة عند البرمجة النصية في Go…
دعونا ننظر إلى واحد آخر، commit
الدالة الأولى في بايثون:
def commit(message, author): tree = write_tree() parent = get_local_master_hash() timestamp = int(time.mktime(time.localtime())) utc_offset = -time.timezone author_time = '{} {}{:02}{:02}'.format( timestamp, '+' if utc_offset > 0 else '-', abs(utc_offset) // 3600, (abs(utc_offset) // 60) % 60) lines = ['tree ' + tree] if parent: lines.append('parent ' + parent) lines.append('author {} {}'.format(author, author_time)) lines.append('committer {} {}'.format(author, author_time)) lines.append('') lines.append(message) lines.append('') data = 'n'.join(lines).encode() sha1 = hash_object(data, 'commit') master_path = os.path.join('.git', 'refs', 'heads', 'master') write_file(master_path, (sha1 + 'n').encode()) return sha1
ثم في الذهاب:
func commit(message, author string, paths []string) string { tree := writeTree(paths) var buf bytes.Buffer fmt.Fprintln(&buf, "tree", hex.EncodeToString(tree)) parent := getLocalHash() if parent != "" { fmt.Fprintln(&buf, "parent", parent) } now := time.Now() offset := now.Format("-0700") fmt.Fprintln(&buf, "author", author, now.Unix(), offset) fmt.Fprintln(&buf, "committer", author, now.Unix(), offset) fmt.Fprintln(&buf) fmt.Fprintln(&buf, message) data := buf.Bytes() hash := hashObject("commit", data) check0(os.WriteFile(".git/refs/heads/master", []byte(hex.EncodeToString(hash)+"n"), 0o664)) return hex.EncodeToString(hash)}
ومن المثير للاهتمام أن إصدار Python هذه المرة أطول: 23 سطرًا مقابل 19 سطرًا في Go. ويرجع هذا في الغالب إلى التعامل الأفضل مع الطوابع الزمنية. مكتبة Go القياسية ليست مثالية، ولكنها كذلك time
الحزمة أفضل من حزمة بايثون time
و datetime
الحزم مجتمعة.
بشكل عام، تبدو مكتبة Go القياسية أكثر تماسكًا وأفضل تصميمًا من مكتبة Python، والتي تبدو وكأنها صممت من قبل العديد من الأشخاص المختلفين على مدار عدة عقود (لأنها كانت كذلك بالفعل).
خاتمة
عند استخدامه مع panic
استنادًا إلى معالجة الأخطاء، يُعد Go مفيدًا لكتابة نصوص برمجية سريعة لسطر الأوامر.
لأكون صادقًا، ما زلت على الأرجح سألجأ إلى لغة بايثون أولاً للحصول على نصوص برمجية سريعة الاستخدام، وذلك بسبب تركيبها المختصر وفهم القائمة (وغيرها) ومعالجة الاستثناءات بشكل افتراضي.
ومع ذلك، بالنسبة لأي شيء أكثر من مجرد نص سريع، سأنتقل بسرعة إلى Go. مكتبتها القياسية مصممة بشكل أفضل io.Reader
و io.Writer
الواجهات ممتازة، وتساعد كتابتها الثابتة خفيفة الوزن في اكتشاف الأخطاء دون إعاقة الطريق.
أحب ذلك إذا كنت رعتني على جيثب – سيحفزني ذلك على العمل في مشاريعي مفتوحة المصدر وكتابة المزيد من المحتوى الجيد. شكرًا!