۱۰ اشتباه رایج در پروژه های Go – راهنمای کاربردی

۱۶۶ بازدید
آخرین به‌روزرسانی: ۵ مهر ۱۴۰۲
زمان مطالعه: ۱۳ دقیقه
دانلود PDF مقاله
۱۰ اشتباه رایج در پروژه های Go – راهنمای کاربردی۱۰ اشتباه رایج در پروژه های Go – راهنمای کاربردی

در این مقاله به بررسی فهرستی از 10 اشتباه رایج در پروژه های Go پرداخته‌ایم. توجه داشته باشید که ترتیب بیان اشتباهات لزوماً نشان‌دهنده اهمیت آن‌ها نیست.

997696

مقدار Enum ناشناس

به مثال ساده زیر توجه کنید:

در این مثال ما یک enum با استفاده از iota ساختیم که حالت زیر را ایجاد می‌کند:

StatusOpen = 0
StatusClosed = 1
StatusUnknown = 2

اکنون تصور کنید این نوع Status بخشی از یک درخواست JSON باشد و قرار است marshalled/unmarshalled شود. می‌توان ساختار زیر را طراحی کرد:

سپس درخواست‌هایی مانند زیر می‌گیریم:

در کد فوق هیچ چیز خاصی وجود ندارد، وضعیت به صورت unmarshalled و StatusOpen است. اینک درخواست دیگری در نظر می‌گیریم که به هر دلیلی مقدار حالت تعیین نشده است:

در این حالت، فیلد Status مربوط به ساختار Request مقدار اولیه‌ای برابر با مقدار صفر می‌گیرند. از این رو به جای StatusUnknown حالت StatusOpen دارد. بهترین رویه این است که مقدار ناشناس را به صورت یک enum با مقدار صفر تعیین کنیم:

در این صورت اگر وضعیت، بخشی از درخواست JSON نباشد، مقدار اولیه آن برابر انتظار ما به صورت StatusUnknown خواهد بود.

بنچمارک کردن

بنچمارک کردن صحیح کار دشواری است. عوامل زیادی وجود دارند که می‌توانند بر نتیجه مفروض تأثیر بگذارند. یکی از رایج‌ترین اشتباهات این است که گول برخی بهینه‌سازی‌های کامپایلر را بخوریم. مثال دقیق زیر را از کتابخانه teivah/bitvector در نظر بگیرید:

این تابع بیت‌های درون یک بازه مفروض را پاک می‌کند. برای تهیه بنچمارک از آن می‌توان به صورت زیر عمل کرد:

در این بنچمارک، کامپایلر متوجه خواهد شد که clear یک «تابع برگ» (leaf function) است، یعنی هیچ تابع دیگری را فراخوانی نمی‌کند و از این رو آن را inline خواهد کرد. زمانی که inline شد، متوجه خواهد شد که برخی عوارض جانبی دارد. بنابراین فراخوانی clear به سادگی پاک می‌شود و نتایج نادقیقی تولید می‌کند.

یک گزینه می‌تواند این باشد که نتیجه را به یک متغیر سراسری به صورت زیر تعیین کنیم:

در کد فوق کامپایلر، نمی‌داند که فراخوانی عارضه جانبی تولید می‌کند یا نه. از این رو بنچمارک کردن درست خواهد بود.

کاربرد گسترده اشاره‌گرها

ارسال یک متغیر با مقدار، موجب ایجاد یک کپی از آن متغیر می‌شود. در حالی که ارسال آن با اشاره‌گر صرفاً آدرس حافظه را کپی می‌کند. از این رو ارسال با اشاره‌گر همواره سریع‌تر است.

اگر شما نیز بر این باور هستید، نگاهی به مثال زیر بیندازید:

کد فوق یک بنچمارک روی یک ساختار داده 0.3 کیلوبایتی است که ابتدا به وسیله اشاره‌گر و سپس با مقدار ارسال و دریافت می‌شود. 0.3 کیلوبایت مقدار زیادی نیست و از نوع ساختار داده‌هایی که به طور روزمره می‌بینیم نیز فاصله چندانی ندارد.

زمانی که این بنچمارک را روی محیط محلی اجرا کنید، می‌بینید که ارسال با مقدار، بیش از 4 بار سریع‌تر از ارسال با اشاره‌گر است. این موضوع تا حدودی خلاف درک شهودی ما است.

توضیح این نتیجه به شیوه مدیریت حافظه در Go ارتباط دارد. توضیح کامل آن خارج از حوصله این مقاله است، اما تلاش می‌کنیم به صورت فشرده به دلیل این مسئله اشاره کنیم.

یک متغیر می‌تواند روی هیپ یا پشته تخصیص یابد. بنابراین به صورت خلاصه:

  • پشته شامل متغیرهای مداوم برای یک goroutine مفروض است. زمانی که تابع بازگشت می‌یابد، متغیرها از پشته برداشته (pop) می‌شوند.
  • هیپ شامل متغیرهای مشترک یعنی متغیرهای سراسری و امثال آن است.

مثال ساده زیر را که یک مقدار بازگشت می‌دهد، در نظر بگیرید:

در کد فوق، متغیر result از سوی goroutine جاری ایجاد شده است. این متغیر به پشته جاری push می‌شود. زمانی که تابع بازگشت یابد، کلاینت یک کپی از این متغیر دریافت خواهد کرد. متغیر خودش از پشته pop می‌شود. این متغیر همچنان در حافظه وجود دارد تا این که از سوی متغیر دیگری پاک شود، اما دیگر نمی‌توان به آن دسترسی یافت.

اینک همان مثال را با اشاره‌گر در نظر بگیرید:

متغیر result همچنان از سوی goroutine جاری ایجاد شده است، اما کلاینت یک اشاره‌گر یعنی یک آدرس متغیر دریافت خواهد کرد. اگر متغیر result از پشته برداشته شود، کلاینت‌های تابع دیگر نمی‌تواند به آن دسترسی پیدا کند.

در این سناریو کامپایلر Go متغیر result را به مکانی منتقل می‌کند که بتواند به اشتراک گذارده شود و اینجا مکانی نیست به جز هیپ.

با این حال ارسال اشاره‌گر نیز یک سناریوی دیگر است. برای مثال:

از آنجا که f را درون همان goroutine فراخوانی می‌کنیم، متغیر p نیازی به انتقال ندارد. این متغیر به سادگی به پشته پوش می‌شود و تابع فرعی می‌تواند به آن دسترسی داشته باشد.

برای نمونه این یک نتیجه مستقیم دریافت یک slice در متد Read مربوط به io.Reader به جای بازگشت دادن یکی از آن‌ها است. بازگشت دادن یک io.Reader (که یک اشاره‌گر است) باعث می‌شود به هیپ انتقال پیدا کند.

اینک سؤال این است که چرا پشته چنین سریع است؟ دو دلیل عمده برای آن وجود دارد:

  • نیازی به garbage collector برای پشته وجود ندارد. چنان که گفتیم متغیر یک بار در زمان ایجاد شدن به پشته ارسال می‌شود و سپس یک بار در زمان بازگشت تابع از آن برداشته می‌شود. بنابراین هیچ نیازی به فرایند پیچیده تملک مجدد متغیرهای استفاده نشده و کارهایی از این دست وجود ندارد.
  • هر پشته به یک goroutine تعلق دارد و از این رو لازم نیست متغیر همانند روش ذخیره‌سازی در هیپ همگام‌سازی شود. این وضعیت موجب بهبود عملکرد نیز می‌شود.

در نتیجه زمانی که یک تابع ایجاد می‌کنیم، رفتار پیش‌فرض ما باید استفاده از مقدار به جای اشاره‌گر باشد. یک اشاره‌گر تنها باید در مواردی استفاده شود که می‌خواهیم متغیری را به اشتراک بگذاریم.

در صورتی که از مشکلات عملکردی رنج می‌برید، یک راه‌حل بهینه‌سازی ممکن، می‌تواند این باشد که بررسی کنید آیا اشاره‌گر در برخی سناریوهای خاص مفید واقع می‌شود یا نه. با استفاده از دستور زیر می‌توان فهمید که آیا یک متغیر به هیپ انتقال می‌یابد یا نه:

در نهایت یک بار دیگر یادآوری می‌کنیم که برای اغلب ما در کاربردهای روزمره، استفاده از ارسال با مقدار، بهترین گزینه محسوب می‌شود.

Break کردن یک for/switch یا یک for/select

در مثال زیر وقتی f مقدار true بازگشت دهد چه رخ می‌دهد؟

ما در این حالت گزاره break را فراخوانی می‌کنیم. اما بدین ترتیب گزاره switch به صورت break درخواهد آمد و نه حلقه for. همین مشکل در مورد مثال زیر نیز وجود دارد:

چنان که می‌بینید Break به گزاره select مربوط است و نه حلقه for. یک راه‌حل ممکن برای break کردن گزاره for/switch یا یک گزاره for/select استفاده از labeled break مانند زیر است:

مدیریت خطا

Go در مسیر مدیریت خطای خود همچنان تا حدودی ناپخته به نظرمی رسد. دقیقاً به همین دلیل است که یکی از بزرگ‌ترین انتظارات ما از نسخه 2 زبان Go حل این مشکل است.

کتابخانه استاندارد کنونی (پیش از Go 1.13) تنها تابع‌هایی برای ساخت خطاها ارائه می‌کرد، بنابراین شاید بهتر باشد نگاهی به pkg/errors (+) داشته باشید.

این کتابخانه روش مناسبی برای رعایت این قاعده ساده است که البته در اغلب موارد نادیده گرفته می‌شود:

یک خطا باید تنها یکبار مدیریت شود. لاگ کردن یک خطا به معنی مدیریت خطا است. بنابراین یک خطا یا باید لاگ شود و یا انتشار یابد.

با استفاده از کتابخانه استاندارد کنونی رعایت این قاعده دشوار است، زیرا ممکن است بخواهیم نوعی چارچوب به یک خطا بیفزاییم و شکلی از سلسله‌مراتب داشته باشیم.

در ادامه مثالی در همین رابطه مشاهده می‌کنید. در این مثال یک فراخوانی REST منجر به ایجاد مشکل در پایگاه داده می‌شود:

unable to server HTTP POST request for customer 1234
 |_ unable to insert customer contract abcd
     |_ unable to commit transaction

اگر از کتابخانه pkg/errors استفاده کنیم، می‌توانیم به صورت زیر عمل کنیم:

خطای اولیه (اگر از سوی یک کتابخانه اکسترنال بازگشت نیافته باشد) می‌تواند با استفاده از errors.New ایجاد شده باشد. لایه میانی insert این خطا را به وسیله افزودن چارچوب بیشتر به آن پوشش می‌دهد. سپس والد با لاگ کردن خطا آن را مدیریت می‌کند. بدن ترتیب در هر سطح یا مقداری بازگشت می‌یابد و یا خطا مدیریت می‌شود.

همچنین می‌توانیم منشأ خطا را نیز بررسی کنیم تا بدانیم آیا به یک تلاش مجدد نیاز داریم یا نه. فرض کنید یک پکیج db از یک کتابخانه بیرونی با دسترسی‌های پایگاه داده سر و کار دارد. این کتابخانه می‌تواند یک خطای گذرا (موقت) به نام db.DBError ایجاد کند. برای تعیین این که آیا به تلاش مجدد نیاز داریم یا نه، باید منشأ خطا را بررسی کنیم:

این کار با استفاده از errors.Cause انجام می‌یابد که آن نیز از پکیج pkg/errors می‌آید:

یک اشتباه رایج، استفاده ناقص از پکیج pkg/errors است. برای نمونه بررسی خطا به صورت زیر اجرا می‌شود:

در این مثال اگر db.DBError پوشش پیدا کند، هرگز موجب تلاش مجدد نمی‌شود.

مقداردهی اولیه Slice

برخی اوقات ما می‌دانیم که طول نهایی یک Slice چه قدر خواهد بود. برای نمونه تصور کنید باید یک Slice به نام Foo را به یک Slice به نام Bar تبدیل کنیم، یعنی دو Slice طولی یکسان دارند. ما غالباً می‌بینیم که Slice-ها به صورت زیر مقداردهی اولیه می‌شوند:

توجه کنید که Slice ساختاری جادویی محسوب نمی‌شود. این ساختار در صورت نبود فضای بیشتر، در پس‌زمینه یک راهبرد رشد را پیاده‌سازی می‌کند. در این حالت یک آرایه جدید به صورت خودکار ایجاد می‌شود که ظرفیت بالاتری دارد و همه آیتم‌ها روی آن کپی می‌شوند.

اینک تصور کنید نیاز داریم این عملیات رشد را چند بار تکرار کنیم، زیرا []Foo ما شامل هزاران آیتم است. پیچیدگی زمانی عملیات درج همچنان (O(1 خواهد بود، اما در عمل با مشکل عملکردی مواجه می‌شویم.

از این رو اگر طول نهایی را بدانیم می‌توانیم به یکی از روش‌های زیر عمل کنیم:

مقداردهی با طول از قبل تعریف‌شده:

یا مقداردهی با طول 0 و ظرفیت از پیش تعریف‌شده:

بهترین گزینه کدام است؟ گزینه اول کمی سریع‌تر است. با این حال ممکن است گزینه دوم را بیشتر ترجیح بدهید زیرا موجب می‌شود همه چیز منسجم‌تر بماند. صرف‌نظر از این که اندازه اولیه را می‌دانیم یا نه، افزودن یک عنصر به انتهای یک slice با استفاده از append انجام می‌یابد.

مدیریت چارچوب (Context)

context.Contex غالباً از سوی توسعه‌دهندگان به اشتباه فهمیده می‌شود. بر اساس مستندات رسمی:

یک چارچوب حامل یک ضرب‌الاجل، یک سیگنال لغو و دیگر مقادیر روی API است.

این توصیف کلی است، اما باعث می‌شود که برخی افراد در مورد چرایی و چگونگی استفاده از آن دچار سردرگمی شوند.

در ادامه آن را بیشتر باز می‌کنیم. یک چارچوب می‌تواند شامل موارد زیر باشد:

  • یک ضرب‌الاجل یا deadline: این ضرب‌الاجل یا شامل یک مدت زمان (برای مثال 250 میلی‌ثانیه) یا یک تاریخ-زمان (مثلاً 2019-01-08 01:00:00) است که در زمان فرا رسیدن این زمان، باید فعالیت جاری یعنی درخواست I/O، انتظار برای ورودی کانال و غیره را لغو کنیم.
  • سیگنال لغو (Cancelation Signal) نیز رفتار مشابهی دارد. زمانی که یک سیگنال دریافت شود باید فعالیت جاری متوقف شود. برای نمونه تصور کنید که ما مشغول دریافت دو درخواست هستیم. فرض کنید یکی برای درج برخی داده‌ها و دیگری برای لغو درخواست اول (چون دیگر موضوعیت ندارد یا هر دلیل دیگر) است. این وضعیت از طریق استفاده از چارچوب قالب لغو در فراخوانی اول میسر است تا به محض دریافت درخواست دوم بتوانیم اولی را لغو کنیم.
  • یک لیست از کلید/مقدار که هر دو از نوع {}interface هستند.

دو نکته وجود دارد که باید اضافه کنیم. ابتدا این که یک چارچوب composable است. بنابراین می‌توانیم یک چارچوب داشته باشیم که شامل یک ضرب‌الاجل باشد و یک لیست از کلید/مقدار نیز برای نمونه داشته باشد. به علاوه goroutine-های چندگانه می‌توانند چارچوب واحدی را به اشتراک بگذارند، بدین ترتیب سیگنال لغو می‌تواند به صورت بالقوه چند فعالیت را متوقف کند.

اگر به موضوع اصلی خود بازگردیم، در اینجا یک اشتباه مهم دیده می‌شود.

یک اپلیکیشن Go بر مبنای urfave/cli ساخته شده است. این کتابخانه‌ای زیبا است که برای ایجاد اپلیکیشن‌های خط فرمان در Go استفاده می‌شود. زمانی که کار توسعه اپلیکیشن آغاز می‌شود، توسعه‌دهنده از نوعی چارچوب اپلیکیشن ارث‌بری می‌کند. این بدان معنی است که وقتی اپلیکیشن متوقف شود، کتابخانه از این چارچوب برای ارسال سیگنال لغو استفاده می‌کند.

در چنین حالتی دقیقاً همین چارچوب مستقیماً در زمان فراخوانی نقطه انتهایی gRPC ارسال می‌شود. ولی این آن چیزی نیست که ما می‌خواهیم. به جای آن ما می‌خواهیم به کتابخانه gRPC اعلام کنیم که درخواست را زمانی که اپلیکیشن متوقف شده است و یا پس از مثلاً 100 میلی‌ثانیه لغو کند.

برای نیل به این مقصود می‌توانیم یک «چارچوب ترکیبی» (composed context) ایجاد کنیم. اگر parent نام چارچوب اپلیکیشن باشد، در این حالت به صورت زیر عمل می‌کنیم:

درک چارچوب‌ها کار چندان پیچیده‌ای نیست و یکی از بهترین ویژگی‌های زبان برنامه‌نویسی Go محسوب می‌شوند.

عدم استفاده از گزینه race-

خطایی که غالباً در زمان تست کردن اپلیکیشن‌های Go مشاهده می‌شود، عدم استفاده از گزینه race- است. با این که Go طوری طراحی شده است که برنامه‌نویسی concurrent راحت‌تر و مستعد خطای کمتری است، اما همچنان از مشکلات همزمانی زیادی رنج می‌برد.

بدیهی است که تشخیص‌دهنده race در Go برای همه مشکلات همزمانی چاره‌ساز نیست. در هر حال این ابزار ارزشمندی است و باید همواره در زمان تست اپلیکیشن‌ها مورد استفاده قرار گیرد.

استفاده از نام فایل به عنوان ورودی

اشتباه رایج دیگر در زمان برنامه‌نویسی Go ارسال نام فایل به یک تابع است. فرض کنید ما می‌خواهیم تابعی را برای شمارش خطوط خالی یک فایل پیاده‌سازی کنیم. طبیعی‌ترین پیاده‌سازی چیزی مانند زیر خواهد بود:

Filename به عنوان یک ورودی عرضه می‌شود و از این رو آن را باز می‌کنیم و سپس منطق خود را پیاده‌سازی می‌کنیم.

اینک فرض کنید می‌خواهیم تست‌های unit را روی این تابع اجرا کنیم تا با یک فایل نرمال، یک فایل خالی، یک فایل با نوع انکودینگ متفاوت و غیره آن را تست کنیم. چنان که حدس می‌زنید مدیریت این کار به سرعت دشوار خواهد شد.

ضمناً اگر بخواهیم همین منطق را پیاده‌سازی کنیم، اما از بدنه HTTP استفاده کنیم، باید تابع دیگری برای آن ایجاد کنیم.

Go دو لایه تجرید عالی به این منظور دارد که یکی io.Reader و دیگری io.Writer است. به جای ارسال یک نام فایل می‌توانیم به سادگی یک io.Reader ارسال کنیم و منبع داده را تجرید نماییم.

شاید بپرسید io.Reader یک فایل است؟ یک بدنه HTTP یک بافر بایت و یا شاید چیز دیگری است؟ این مسئله اهمیتی ندارد زیرا ما همچنان همان متد Read را داریم.

در این مورد می‌توانیم حتی ورودی را بافر کنیم تا آن را خط به خط بخوانیم. بنابراین می‌توانیم از bufio.Reader و از متد ReadLine آن استفاده کنیم:

مسئولیت باز کردن خود فایل اکنون به کلاینت count واگذار شده است:

در پیاده‌سازی دوم تابع می‌تواند صرف‌نظر از این که منبع داده واقعی چیست فراخوانی شود. در عین حال، تست‌های واحد تسهیل می‌شوند، زیرا می‌توان bufio.Reader از یک string ساخت:

Goroutine-ها و متغیرهای حلقه

آخرین اشتباه رایجی که در این مقاله بررسی می‌کنیم به استفاده از متغیرهای حلقه در Goroutine ها مربوط است. خروجی مثال زیر چیست؟

اگر فکر می‌کنید خروجی مثال فوق 1 2 3 با هر نوع ترتیبی است اشتباه می‌کنید. در این مثال هر goroutine وهله یکسانی از متغیر را به اشتراک می‌گذارد و از این رو خروجی آن 3 3 3 است.

دو راه‌حل برای این مشکل وجود دارد. راه‌حل نخست این است که مقدار متغیر i را به بستار (تابع درونی) ارسال کنیم:

و راه‌حل دوم این است که متغیر دیگری درون دامنه حلقه for ایجاد کنیم:

با این که فراخوانی i:= i ممکن است کمی عجیب به نظر برسد، اما کاملاً معتبر است. درون یک حلقه بودن یعنی درون یک دامنه دیگر قرار داریم. بنابراین i:= i یک وهله متغیر دیگری به نام i می‌سازد. البته می‌توانیم آن را به نام دیگری نام‌گذاری کنیم تا خوانایی کد افزایش یابد.

اگر این مطلب برای شما مفید بوده است، آموزش‌های زیر نیز به شما پیشنهاد می‌شوند:

==

بر اساس رای ۱ نفر
آیا این مطلب برای شما مفید بود؟
اگر بازخوردی درباره این مطلب دارید یا پرسشی دارید که بدون پاسخ مانده است، آن را از طریق بخش نظرات مطرح کنید.
منابع:
itnext
دانلود PDF مقاله
نظر شما چیست؟

نشانی ایمیل شما منتشر نخواهد شد. بخش‌های موردنیاز علامت‌گذاری شده‌اند *