أنماط معالجة الأخطاء في Rust: متى تستخدم Result ومتى تستخدم anyhow وthiserror؟

مقدمة

تُعد معالجة الأخطاء من أبرز السمات التي تجعل Rust مناسبة لبناء البرمجيات الموثوقة؛ إذ لا تسمح اللغة عادةً بتجاهل حالات الفشل كما يحدث في نماذج الاستثناءات التقليدية. بدلاً من ذلك، تُمثّل النتيجة الناجحة أو الفاشلة صراحةً في نوع القيمة المعادة، ما يدفع المطوّر إلى التفكير في كل مسار محتمل لتنفيذ البرنامج.

في موضوع أنماط معالجة الأخطاء في Rust: متى تستخدم Result ومتى تستخدم anyhow وthiserror؟، لا توجد أداة واحدة صالحة لكل الحالات. فـResult هو الأساس اللغوي لمعالجة الأخطاء القابلة للاسترداد، وتساعد مكتبة thiserror على إنشاء أنواع أخطاء دقيقة ومقروءة، بينما توفّر anyhow أسلوباً عملياً لتجميع الأخطاء ونشرها بسرعة داخل التطبيقات. يعتمد الاختيار الصحيح على سؤال جوهري: هل يحتاج مستدعي الدالة إلى معرفة نوع الخطأ والتصرف بناءً عليه، أم يكفي تسجيل الخطأ وعرضه للمستخدم وإنهاء العملية أو إلغاؤها؟

فهم Result بوصفه الأساس في Rust

يُعرّف النوع Result<T, E> نتيجة عملية قد تنجح أو تفشل. تمثل Ok(T) القيمة الناجحة من النوع T، بينما تمثل Err(E) الخطأ من النوع E. وهذه ليست آلية إضافية من مكتبة خارجية، بل جزء أصيل من مكتبة Rust القياسية ومن نظام الأنواع فيها.

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

use std::fs;

fn اقرأ_الإعدادات(المسار: &str) -> Result<String, std::io::Error> {
    fs::read_to_string(المسار)
}

fn main() {
    match اقرأ_الإعدادات("config.toml") {
        Ok(المحتوى) => println!("تم تحميل الإعدادات:\n{}", المحتوى),
        Err(الخطأ) => eprintln!("تعذر تحميل الملف: {}", الخطأ),
    }
}

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

use std::fs;
use std::io;

fn أول_سطر(المسار: &str) -> Result<String, io::Error> {
    let المحتوى = fs::read_to_string(المسار)?;
    Ok(المحتوى.lines().next().unwrap_or_default().to_string())
}

لا يعني استخدام Result بالضرورة كتابة أنواع أخطاء معقدة منذ البداية. في الدوال الصغيرة أو الداخلية قد يكون Result<T, std::io::Error> أو Result<T, String> كافياً مؤقتاً، وإن كان النوع String أقل فائدة عندما يحتاج المستدعي إلى التمييز البرمجي بين حالات الفشل.

متى تصمم نوع خطأ خاصاً باستخدام thiserror؟

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

تقوم المكتبة بتوليد تطبيقات السمات القياسية مثل Display وError وFrom باستخدام المشتقة derive. وبهذا يحصل المطوّر على رسائل واضحة وسلسلة أسباب أخطاء سليمة، من دون كتابة قدر كبير من الشيفرة المتكررة.

use thiserror::Error;

#[derive(Debug, Error)]
pub enum خطأ_الحساب {
    #[error("المستخدم ذو المعرّف {0} غير موجود")]
    غير_موجود(u64),

    #[error("العمر {0} خارج النطاق المسموح")]
    عمر_غير_صالح(u8),

    #[error("فشل الوصول إلى التخزين")]
    تخزين(#[from] std::io::Error),
}

pub fn تحقق_من_العمر(العمر: u8) -> Result<(), خطأ_الحساب> {
    if العمر < 18 {
        return Err(خطأ_الحساب::عمر_غير_صالح(العمر));
    }

    Ok(())
}

في هذا المثال، يستطيع المستدعي مطابقة متغيرات التعداد بوضوح:

match تحقق_من_العمر(16) {
    Ok(()) => println!("يمكن إنشاء الحساب"),
    Err(خطأ_الحساب::عمر_غير_صالح(_)) => {
        println!("يجب أن يكون العمر 18 عاماً أو أكثر");
    }
    Err(الخطأ) => eprintln!("خطأ آخر: {}", الخطأ),
}

هذا النمط مهم جداً في المكتبات العامة، وحزم النطاقات التجارية، وواجهات الشبكة، وطبقات الوصول إلى قواعد البيانات. فالمكتبة الجيدة لا تكتفي بإخبار مستدعيها بأن «شيئاً ما فشل»، بل تمنحه معلومات منظمة يستطيع البناء عليها. كما أن المتغير #[from] يسهّل تحويل أخطاء الطبقات الأدنى، مثل أخطاء الإدخال والإخراج، إلى نوع خطأ المجال الخاص بالتطبيق أو المكتبة.

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

متى يكون anyhow الخيار الأنسب للتطبيقات؟

تقدم مكتبة anyhow النوع anyhow::Error، وهو نوع خطأ عام ومُعتم يلتف حول أخطاء متعددة تطبق السمة Error. ويستعمل عادةً مع الاسم المستعار anyhow::Result<T>. وهي مناسبة خصوصاً في التطبيقات التنفيذية، مثل أدوات سطر الأوامر، والخدمات الداخلية، وبرامج الاستيراد والمعالجة الدورية، حيث لا يحتاج معظم المستدعين إلى مطابقة كل نوع خطأ بدقة.

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

use anyhow::{Context, Result};
use std::fs;

fn حمّل_القالب(المسار: &str) -> Result<String> {
    let المحتوى = fs::read_to_string(المسار)
        .with_context(|| format!("تعذر قراءة ملف القالب: {}", المسار))?;

    if المحتوى.trim().is_empty() {
        anyhow::bail!("ملف القالب فارغ");
    }

    Ok(المحتوى)
}

تضيف with_context طبقة تفسيرية لا توفرها رسالة خطأ الإدخال والإخراج وحدها. فعوضاً عن ظهور رسالة مثل «لا يوجد ملف أو دليل»، يمكن لسجل التطبيق أن يوضح أن الفشل حدث أثناء تحميل قالب محدد. كما تتيح anyhow! إنشاء خطأ مباشرةً، وتتيح bail! إنهاء الدالة فوراً بخطأ مناسب.

ومع ذلك، لا يُفضّل عادةً كشف anyhow::Error في واجهة مكتبة عامة؛ لأنه يخفي الأنواع التي قد يحتاج المستدعي إلى التعامل معها. صحيح أنه يمكن محاولة استرجاع النوع الأصلي عبر downcast_ref، لكن هذا يجعل العقد ضمنياً وهشاً مقارنةً بتعداد واضح من أخطاء thiserror.

الاختيار العملي بين Result وthiserror وanyhow

من المهم إدراك أن هذه الأدوات ليست بدائل متنافية تماماً. فكل من thiserror وanyhow يعملان فوق مفهوم Result. لذلك فالسؤال الأدق ليس: هل أستخدم Result أم anyhow؟ بل: ما نوع الخطأ الذي يجب أن يرافق Result في هذه الطبقة من البرنامج؟

استخدم Result<T, E> مباشرةً عندما يكون نوع E بسيطاً ومتوفراً أصلاً، مثل std::io::Error أو ParseIntError. واستخدم thiserror عندما تريد تعريف أخطاء نطاقك الخاص وتقديمها إلى مستدعين آخرين بوصفها جزءاً مستقراً من الواجهة. أما anyhow فاختره عند أطراف التطبيق، حيث يكون الهدف تسجيل الفشل، وإضافة السياق، وتحويله إلى رسالة للمستخدم أو إلى رمز خروج مناسب.

يمكن تنظيم مشروع نموذجي وفق هذا المبدأ: طبقة المكتبة أو المنطق التجاري تعيد Result<T, خطأ_النطاق> باستخدام thiserror، بينما تستعمل دالة main أو معالج الطلبات في التطبيق anyhow::Result. عندئذ تبقى الأخطاء قابلة للمعالجة المنظمة في الداخل، وتصبح سهلة النشر والتسجيل عند الحدود الخارجية.

use anyhow::Result;

fn main() -> Result<()> {
    let المستخدم = حمّل_مستخدم(42)?;
    println!("مرحباً، {}", المستخدم);
    Ok(())
}

// قد تعيد هذه الدالة Result<String, خطأ_الحساب>
// ويُحوَّل خطؤها تلقائياً إلى anyhow::Error في main.
fn حمّل_مستخدم(المعرّف: u64) -> Result<String, خطأ_الحساب> {
    if المعرّف == 0 {
        return Err(خطأ_الحساب::غير_موجود(المعرّف));
    }
    Ok("ليلى".to_string())
}

السياق وسلسلة الأسباب في التطبيقات الواقعية

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

تدعم thiserror وanyhow مفهوم سلسلة الأسباب. فعند استخدام #[from] في thiserror أو العامل ? في anyhow، يحتفظ الخطأ عادةً بالسبب الأصلي. وهذا ضروري في السجلات والتشخيص، لأن رسالة عالية المستوى وحدها قد تخفي السبب الفني الذي يحتاجه فريق التشغيل.

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

use anyhow::{Context, Result};

async fn نفّذ_المهمة() -> Result<()> {
    let المقبض = tokio::spawn(async {
        عملية_شبكية().await
    });

    المقبض
        .await
        .context("تعذر انتظار اكتمال المهمة غير المتزامنة")?
        .context("فشلت العملية الشبكية داخل المهمة")?;

    Ok(())
}

async fn عملية_شبكية() -> Result<()> {
    Ok(())
}

أخطاء شائعة واستراتيجية اختبار فعالة

من أكثر الأخطاء شيوعاً استخدام unwrap() أو expect() في مسارات قد تفشل فعلياً في بيئة الإنتاج. يمكن قبول expect عند تثبيت افتراض معروف مع رسالة دقيقة، أو في الاختبارات والنماذج السريعة، لكنه ليس بديلاً عن معالجة أخطاء الملفات والشبكات والمدخلات الخارجية.

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

ينبغي اختبار مسارات الفشل كما تُختبر مسارات النجاح. يمكن كتابة اختبارات تتحقق من المتغير المتوقع في خطأ thiserror، واختبارات أخرى تتحقق من احتواء رسالة anyhow على السياق المطلوب. وفي التطبيقات الشبكية، من المفيد محاكاة انتهاء المهلة، والاستجابات غير الصالحة، وفشل التخزين، حتى لا تبقى المعالجة النظرية غير مجرّبة.

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

خاتمة

تعتمد معالجة الأخطاء الجيدة في Rust على جعل الفشل جزءاً واضحاً من تصميم البرنامج، لا حالة جانبية مخفية. استخدم Result دائماً لتمثيل العمليات القابلة للفشل، واختر نوع الخطأ وفق احتياجات المستدعي. عندما تكون التفاصيل قابلة للتصرف ومهمة لعقد المكتبة أو منطق المجال، استخدم thiserror وأنشئ أنواعاً دقيقة. وعندما تبني تطبيقاً يحتاج أساساً إلى نشر الأخطاء وإثرائها بالسياق وتسجيلها، فإن anyhow يوفر تجربة عملية ومختصرة.

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

تعليقات