سارا رضائی
سارا رضائی
خواندن ۱۰ دقیقه·۳ سال پیش

مدیریت خطا

یکی از منابع مهم پیچیدگی در سیستم های نرم افزاری، exception ها هستند. نوشتن کدی که با شرایط خاص سر و کار دارد، خیلی سخت تر از کدی است که یک کار ساده را در شرایط نرمال انجام می دهد. در این نوشته، این موضوع را بررسی می کنیم که exception ها چگونه بر پیچیدگی نرم افزار تاثیر می گذارند و چطور می توان آن ها را بهتر مدیریت کرد.

چرا exception ها به پیچیدگی می افزایند؟

ابتدا exception را تعریف کنیم. به شرایطِ غیرعادی، که جریانِ نرمالِ نرم افزار را مختل می کند exception گفته می شود. خیلی از زبان های برنامه نویسی، مکانیزمی دارند که به کد اجازه می دهد یک exception تولید کند تا توسط کدی که از آن استفاده می کند گرفته و مدیریت شود. ولی exception ها می توانند بدون این مکانیزم هم اتفاق بیفتند. برای مثال، در نظر بگیرید یک متد، زمانی که نتواند رفتار نرمال خودش را به درستی انجام دهد و دچار مشکل می شود، در خروجی خود یک مقدار مشخص را بر می گرداند (مثلا یک کد خطا)، که به صدازننده ی خود بگوید، کار درست انجام نشده است. به این شرایط هم exception می گوییم.

اگر با زبان هایی مثل #C یا Java کار کرده اید، احتمالا exception برای شما بخشی از syntax زبان است. در خواندن این نوشته، زبان را فراموش کنید و با مفهومِ exception، به صورتی که توضیح داده شد برخورد کنید.

چند مثال از دلایل اتفاق افتادن exception:

  • قطعه کدی که یک متد را صدا زده، ممکن است پارامترهای اشتباهی به آن پاس داده باشد.
  • متد ممکن است نتواند آن چه که پیاده سازی شده را انجام دهد. مثلا یک عملیات IO به دلیل پیدا نشدن فایل به مشکل می خورد یا یک منبع خارجی که متد می خواهد با آن کار کند، مثل دیتابیس، در دسترس نیست.
  • در سیستم های توزیع شده، packet های شبکه ممکن است گم شوند یا تاخیر داشته باشند یا ممکن است server در زمانِ مناسب پاسخ ندهد.
  • کد دچار bug شود یا با موقعیت هایی برخورد کند که برای رویارویی با آن ها آماده نیست.

هنگام پیاده سازی نرم افزار، نوشتن کدی که exception را مدیریت می کند، بسیار سخت تر از نوشتن کدی است که قرار است فقط با شرایط نرمال کار کند. یک exception، جریان عادی کد را قطع و در آن اختلال ایجاد می کند.

وقتی یک exception اتفاق می افتد، برنامه نویس می تواند دو کار انجام دهد:

  1. به exception توجه نکند و کار را جلو ببرد. مثلا، اگر یک packet شبکه گم شد، آن را دوباره بفرستد. اگر دیتابیس در دسترس نیست، دیتای قدیمی تری که جای دیگری ذخیره شده را برگرداند (مثلا موقتا از cache بخواند، در حالی که می داند زمان انقضای آن گذشته است)

این روش مشکلاتی دارد. در مثالِ گم شدن packet، ممکن است که آن packet گم نشده بوده و تنها تاخیر داشته است. در این صورت، اگر از روشِ ارسالِ دوباره استفاده کرده باشیم، یک packet، دو بار به مقصد می رسد. در نتیجه، برای گیرنده ی packet، یک شرایط exception خیز فراهم کردیم و گیرنده باید آن را مدیریت کند. (برای مثال، چک کند که packet تکراری است یا نه، و در صورت تکراری بودن، تصمیم بگیرد که این شرایط را چطور مدیریت کند)

یا در مثالِ در دسترس نبودن دیتابیس، اگر دیتای قدیمی تر هم در دسترس نباشد چه؟ اگر همه ی کپی هایی که از دیتابیس داشتیم، از دسترس خارج شده بودند، چگونه exception را مدیریت کنیم؟

  1. روش دوم این است که هر جا exception اتفاق افتاد، عملیات را متوقف کند و exception را به سطح بالاتر انتقال دهد.

استفاده از این روش هم مشکلاتی دارد. ممکن است در آن نقطه که عملیات متوقف می شود، سیستم در یک وضعیت (state) نامناسب باشد. در این صورت، کدی که exception را مدیریت میکند، وظیفه ی برگرداندنِ وضعیت سیستم به یک وضعیت پایدار را هم دارد. مثلا شاید یک سری تغییرات که قبل از اتفاق افتادنِ exception اعمال شده بودند، باید rollback شوند.

همچنین، در زبان هایی که مکانیزم های مدیریت exception دارند (مانند C# و Java)، ممکن است خواندن کد سخت شود. قطعه کد زیر را در نظر بگیرید:

try { ... } catch (FileNotFoundException e) { // Do Something } catch (ClassNotFoundException e) { // Do Something } catch (EOFException e) { // Not a problem } catch (IOException e) { // Do Something } catch (ClassCastException e) { // Do Something }

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

یک راه دیگر این است که برای هر قسمت از کد که امکانِ اتفاق افتادنِ خطا دارد، یک try جداگانه بنویسیم. در این صورت مشخص خواهد شد که exception دقیقا کجا رخ داده است. ولی هم خواندن کد سخت می شود و هم جریان اجرای کد مختل خواهد شد.

مساله ی دیگر این است که بسیاری مواقع، کدهایی برای مدیریت exception می نویسیم، ولی از کار کردن آن ها اطمینان حاصل نمی کنیم. مثلا خطاهای IO را نظر بگیرید. معمولا در محیط تست، این خطاها مشخص نمی شوند و کدی که exception مربوط به این بخش ها را مدیریت می کند هیچ وقت تست نمی شود.



یکی از عادت های بد برنامه نویس ها این است که، در هنگام مدیریت خطا، با تعریف exception های غیرضروری، اوضاع را بدتر می کنند. گویی آن ها معتقدند: "هرچقدر exception های بیشتری شناسایی شود، بهتر است". به هر چیز کوچکی که مشکوک شوند، یکی exception تولید می کنند و این باعث می شود پیچیدگی سیستم بیشتر شود.

راه حل های بهتری هم وجود دارد. هر بار که اتفاق غیرنرمالی در سیستم می افتد، ناچار نیستیم حتما exception تولید کنیم. مثلا فرض کنید متدی داریم، و در بخشی از عملکرد آن، می خواهیم یک فایل را حذف کنیم. در این سناریو، شرایطِ نرمال این است که فایل وجود دارد، و نرم افزار به راحتی آن را حذف می کند. یکی از شرایط غیرنرمال، این است که فایل وجود نداشته باشد. برای مدیریت این شرایط غیرنرمال، دو راه حل می توانیم داشته باشیم:

1. یک exception تولید کرده و به صدازننده ی این متد اعلام کنیم که فایل موجود نیست. در این صورت، وظیفه ی حل مساله را به آن صدازننده ی بیچاره محول کرده ایم، در حالی که ممکن است او هم نداند که این خطا را چگونه مدیریت کند. این روش به پیچیدگی سیستم می افزاید.

2. به جای تولید exception، به بیزنس متد فکر کنیم. مثلا، شاید بیزنس به گونه ای باشد، که هدف نهایی، عدم وجود آن فایل روی دیسک است، بنابراین، اگر فایل وجود دارد، آن را حذف می کنیم و اگر وجود ندارد، چه بهتر! . با این تصمیم گیری، برنامه نویس، راه حل راحت را (که پرتاب کردن یک exception به سمت یک کد دیگر است) انتخاب نکرده، بلکه آگاهانه به این فکر کرده، که منطقِ آن قطعه از کد، چه پتانسیل هایی برای مدیریت این خطا دارد.

در نوشته ی مربوط به برنامه نویسی ماژولار اشاره شد که هر ماژول دو نوع واسط می تواند داشته باشد:

  • واسط رسمی: آنچه که در ظاهر آن ماژول مشخص است. مثلا signature یک متد
  • واسط غیر رسمی: آنچه که به دنیای بیرون از ماژول مربوط است، ولی در رفتار ماژول پنهان شده است.

یک exception، یک عنصر پیچیده از واسط ماژول است. نه فقط در خود آن ماژول تاثیر می گذارد، بلکه بر تمام کسانی که از آن استفاده می کنند موثر است و بر پیچیدگی آن ها می افزاید.

تولید یک exception ساده است و handle کردن آن سخت. بهترین راه برای کم کردن آسیب ناشی از exception ها، این است که تعداد قسمت هایی از کد که باید به exception رسیدگی کنند را کاهش دهیم.




در ادامه می خواهیم چهار روش را بررسی کنیم، که به ما کمک می کنند، نیاز به مدیریت خطا را در سیستم کاهش دهیم (در واقع، تعداد بخش هایی از سیستم، که باید به exception رسیدگی کنند را تا جای ممکن کم کنیم):

روش اول: حذف خطا

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

این روش را با یک مثال بررسی کنیم:

در سیستم عامل ویندوز، اگر یک فایل در حال استفاده باشد، امکان حذف آن وجود ندارد. حال اگر کاربر بخواهد یک فایلِ در حال استفاده را حذف کند، باید زمان زیادی را صرف پیدا کردن آن process که در حال استفاده از فایل است کند و حتی گاهی وقت ها کاربر ترجیح می دهد کل سیستم را reboot کند تا فایل آزاد شود. بنابراین یک مدیریت خطای اشتباه، کاربر را به دردسر می اندازد.

در سیستم عامل Unix، اگر کاربر بخواهد فایلی که در حال استفاده است را حذف کند، هیچ خطایی دریافت نمی کند و فایل هم در لحظه حذف نمی شود. در عوض، آن فایل برای حذف شدن علامتگذاری می شود و هیچ process دیگری نمی تواند آن را باز کند و تنها process هایی که از قبل آن را باز کرده اند می تواند write و read روی آن داشته باشند. به محض این که فایل بسته شد، سیستم عامل آن را حذف می کند.

رویکرد سیستم عامل Unix دو نوع خطا را حذف می کند:

1. آن process ای که می خواهد فایل را حذف کند، با exception مواجه نمی شود. عملیات حذف با موفقیت انجام می شود و فایل هم "در نهایت" حذف خواهد شد.

2. آن process که دارد از فایل استفاده می کند، با این خطا مواجه نمی شود که "فایل حذف شده است"، بلکه می تواند به کار خود ادامه دهد.

با این رویکرد، Unix توانسته، دو process را، از زحمتِ مدیریت exception در امان بدارد. شاید برای کسانی که با سیستم عامل windows بیشتر کار کرده اند، این روال عجیب باشد، ولی نکته ی اصلی در این مثال، این است که گاهی اگر به شرایط غیر نرمال، از نگاه دیگری بنگریم، روش های خوبی برای مدیریت آن پیدا می کنیم.

روش دوم: پوشاندن خطا

در این روش، شرایطی در کد که ممکن است به exception منتهی شوند، در ماژول های سطح پایین تر مدیریت می شوند، به گونه ای که کد سطح بالا، اصلا اطلاعی از این شرایط و exception های آن ندارد. این روش معمولا برای سیستم های توزیع شده مناسب است. برای مثال، پروتکل TCP، به جای این که زمانی که یک packet گم می شود، یک exception به سطح بالاتر پرتاب کند، خطا را می پوشاند و packet های گم شده را مجددا ارسال می کند. در واقع در این مثال، شرایطِ غیر نرمالی که در سطح پایین تر (یعنی شبکه) اتفاق افتاده، برای سطح بالاتر (مثلا نرم افزار) مزاحمتی ایجاد نمی کند.

روش سوم: تجمیع خطاها

ایده ی اصلی این روش این است که، تعداد زیادی exception را، فقط در یک قطعه از کد مدیریت کنیم. به جای این که تعداد زیادی رسیدگی کننده به exception داشته باشیم، تنها یک قطعه کد، آن ها را مدیریت کند.

برای مثال، فرض کنید تعدادی کلاس handler داریم که توسط یک کد سطح بالاتر استفاده می شوند. به جای این که exception ها را در کلاس های handler مدیریت کنیم، آن را به کد سطح بالاتر می سپاریم.

تفاوت این روش با روش پوشاندن خطا در این است که، در روش پوشاندن خطا، یک ماژول داریم که توسط چندین ماژول دیگر استفاده می شود، پس به جای انتشار exception به تمام آن ها، در خود آن ماژول، exception را می پوشانیم، ولی در روش تجمیع، چندین ماژول داریم که توسط یک ماژول استفاده می شود. به طور کلی دقت کنید هدف این است که تعداد جاهایی که exception را مدیریت می کنند به حداقل برسانیم.

روش چهارم: توقف اجرا

در بیشتر نرم افزارها، exception هایی وجود دارد که ارزش مدیریت کردن ندارد. یا به این دلیل که خیلی کم اتفاق می افتند و یا مدیریت آن ها خیلی سخت است. بهترین روش برای این نوع exception ها این است که اجرای نرم افزار متوقف شود و exception به بیرون پرتاب شود.

برای مثال، خطای out of memory را در نظر بگیرید. زمانی که حافظه فضا ندارد، نرم افزار چه کاری می تواند انجام دهد؟ شاید بتوان با راه های عجیب، به نحوی این exception را مدیریت کرد، ولی احتمالا آن قدر که پیچیدگی به سیستم اضافه می کند، فایده ندارد. بنابراین بهترین کار، توقف اجرای برنامه و ایجاد exception است.



خلاصه

مهم ترین مساله در مدیریت شرایط غیرنرمال، این است که تعداد جاهایی که exception باید مدیریت شود را کاهش دهیم. در بسیاری مواقع، منطقِ یک عملیات می تواند طوری تغییر کند، که رفتار نرمال، همه ی شرایط را پوشش دهد و نیازی به کشف و مدیریت شرایط استثنا نباشد.



منبع

A philosophy of software design

توسعه نرم افزارطراحی نرم افزارمدیریت خطاخطا در نرم افزاربرنامه نویسی
linkedin.com/in/sara-rez
شاید از این پست‌ها خوشتان بیاید