نمط الأمر (Command)

من موسوعة حسوب

نمط الأمر (Command) هو نمط تصميم سلوكي (Behavioral Design Pattern) يحول الطلب إلى كائن مستقل بذاته بداخله كل بيانات الطلب، ويسمح لك هذا التحول بإدخال طلبات مختلفة كمعامِلات (Parameters) داخل الأساليب، وتأخير تنفيذ الطلب أو وضعه في صف انتظار، ودعم العمليات غير الممكنة (Undoable).

المشكلة

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

الصورة. (ش.1) كل أزرار التطبيق تنحدر من نفس الفئة.

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

الصورة. (ش.2) فئات فرعية كثيرة لفئة Button، ما أسوأ ما قد يحدث؟

لكن مع الوقت سترى أن هذا المنظور به الكثير من العيوب، فلديك عدد هائل من الفئات الفرعية، ولم يكن هذا ليمثل مشكلة لو لم تكن تخاطر بتعطيل الشيفرة في هذه الفئات الفرعية في كل مرة تعدِّل فيها فئة Button الأساسية. فكأن شيفرة الواجهة الرسومية للمحرر صارت معتمدة بشكل غريب على شيفرة متطايرة لمنطق العمل (Business Logic).

الصورة. (ش.3) عدة فئات لها نفس الوظيفة.

والمشكلة هنا أن بعض العمليات مثل النسخ واللصق ستحدث من أماكن متعددة، فمثلًا قد يضغط مستخدم على زر "نسخ" صغير على شريط الأدوات، أو ينسخ شيئًا من خلال القائمة المنسدلة، أو ربما حتى يستخدم اختصار لوحة المفاتيح المشهور Ctrl+C.

ولم يكن ثمة بأس في وضع تطبيقات العمليات المختلفة في فئات الزر الفرعية حين لم يكن في المحرر سوى شريط الأدوات، بمعنى أن وجود شيفرة نسخ النصوص داخل فئة CopyButton فرعية مثلًا كان لا بأس به. لكن عندما تستخدم القوائم السياقية (Context Menues) والاختصارات وغيرها، تكون مضطرًا إلى تكرار شيفرة العملية في عدة فئات أو جعل القوائم معتمدة على الأزرار، وذاك خيار أسوأ من الأول.

الحل

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

وقد تبدو الشيفرة التي تعبر عن مثالنا هذا كالتالي: يستدعي كائن GUI أسلوبًا من كائن منطق عمل، ممررًا إليه بعض الوسائط (Arguments)، وتوصف هذه العملية بأنها كائن يرسل طلبًا إلى كائن آخر.

الصورة. (ش.4) قد تتواصل كائنات الواجهة الرسومية مع كائنات منطق العمل مباشرة.

ويقترح نمط الأمر (Command) أن كائنات الواجهة الرسومية يجب ألا ترسل تلك الطلبات بشكل مباشر، بل يجب أن تستخرج كل تفاصيل الطلب مثل الكائن المستدعَى واسم الأسلوب وقائمة الوسائط (Arguments)، تستخرجها إلى فئة command مع أسلوب وحيد يشغِّل (trigger) هذا الطلب.

وتتصرف كائنات الأمر (Command Objects) هنا كروابط بين كائنات الواجهة الرسومية وكائنات منطق العمل المختلفة، ومن تلك النقطة فإن كائن الواجهة الرسومية لا يحتاج أن يعرف أي كائن منطق عمل سيستلم الطلب ولا كيف سيعالجه، بل هو -أي كائن الواجهة- يبدأ الأمر الذي يعالج كل تلك التفاصيل.

الصورة. (ش.5) الوصول إلى طبقة منطق العمل من خلال أمر.

والخطوة التالية هي جعل أوامرك تستخدم نفس الواجهة، وعادة يكون لديها أسلوب تنفيذ وحيد لا يأخذ أي معامِلات (Parameters)، وتسمح لك هذه الواجهة باستخدام أوامر مختلفة مع نفس مرسل الطلب دون ربط ذلك مع الفئات الحقيقية للأوامر. وزيادة على ذلك تستطيع الآن تبديل كائنات الأمر المرتبطة مع المرسل، مغيرًا بهذا سلوك المرسل أثناء وقت التشغيل (runtime).

ويكون الأمر (Command) مجهز مسبقًا بتفاصيل الطلب التي ستُرسَل إلى المستقبِل أو يستطيع الحصول عليها بنفسه، ذلك أننا نريد تمرير تفاصيل الطلب إلى المستقبِل لكن أسلوب تنفيذ الأمر ليس فيه أي معامِلات (Parameters).

الصورة. (ش.6) تفوض كائنات الواجهة الرسومية العمل إلى الأوامر.

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

أما عناصر الواجهة الرسومية الأخرى مثل القوائم والاختصارات أو الصناديق الحوارية، فيمكن تطبيقها (Implement) بنفس الطريقة، وستكون تلك العناصر مرتبطة بأمر يُنفَّذ عندما يتفاعل مستخدم مع عنصر الواجهة الرسومية، وستكون العناصر المتعلقة بنفس العمليات مرتبطة بنفس الأوامر لمنع تكرار الشيفرة. وكنتيجة لهذا تشكل الأوامر طبقة وسطى تقلل الازدواج أو الربط (Coupling) بين طبقتي الواجهة الرسومية ومنطق العمل، وهذه النتيجة جزء بسيط من منافع نمط الأمر.

مثال واقعي

الصورة. (ش.7) إعداد طلب في مطعم.

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

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

البنية

الصورة. (ش.8)

  1. تكون فئة المرسِل Sender (أو المستدعي Invoker) مسؤولة عن بدء الطلبات، ويجب أن تحتوي هذه الفئة على حقل لتخزين مرجع إلى كائنِ أمر، ويشغِّل المرسل هذا الأمر بدلًا من إرسال الطلب مباشرة إلى المستقبِل، لاحظ أن المرسل ليس مسؤولًا عن إنشاء كائن الأمر، فعادة ما يحصل على أمر منشأ مسبقًا من العميل من خلال المنشئ (Constructor).
  2. تصرح واجهة الأمر Command عادة عن أسلوب واحد لتنفيذ الأمر.
  3. تستخدم الأوامر الحقيقية Concrete Commands أنواعًا مختلفة من الطلبات، ولا يفترض بالأمر الحقيقي أن ينفذ العمل بنفسه، بل يمرر الاستدعاء إلى أحد كائنات منطق العمل. لكن يمكن دمج تلك الفئات بداعي تبسيط الشيفرة.
  4. تحتوي فئة المستقبِل Receiver على بعض منطق العمل، ويتصرف أي كائن تقريبًا كمستقبِل، وتعالج أغلب الأوامر تفاصيل تمرير الطلب إلى المستقبِل فقط، بينما ينفذ المستقبل نفسه أغلب العمل الفعلي.
  5. ينشئ العميل Client كائنات الأمر الحقيقي ويهيؤها، ويجب أن يمرر العميل كل معامِلات الطلب بما فيها نسخة المستقبِل (Receiver Instance) إلى منشئ الأمر، ثم يكون الأمر الناتج بعدها مرتبطًا مع مرسِل واحد أو أكثر.

مثال توضيحي

في هذا المثال يساعد نمط الأمر على تتبع تاريخ العمليات المنفَّذة ويجعل عكس أي عملية ممكنًا عند الحاجة إلى ذلك.

الصورة. (ش.9) العمليات غير القابلة للعكس -لا يمكن التراجع عنها Undoable- في محرر نصي.

تنشئ الأوامر التي تُحدث تغييرات في حالة المحرر (مثل النسخ واللصق) نسخةً احتياطية من حالة المحرر قبل تنفيذ العملية المرتبطة بالأمر، ويوضع في سجل الأوامر بعد تنفيذه (سجل الأوامر Command History: مكدَّس من كائنات الأمر) مع النسخة الاحتياطية من حالة المحرر في تلك النقطة.

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

// تصرح فئة الأمر الأساسية عن واجهة مشتركة لكل 
// (Concrete Commands) الأوامر الحقيقية.
abstract class Command is
    protected field app: Application
    protected field editor: Editor
    protected field backup: text

    constructor Command(app: Application, editor: Editor) is
        this.app = app
        this.editor = editor

    // أنشئ نسخة احتياطية من حالة المحرِّر.
    method saveBackup() is
        backup = editor.text

    // استعِدْ حالة المحرِّر.
    method undo() is
        editor.text = backup

    // يُصرح عن أسلوب  التنفيذ على أنه أسلوم مجرد، من أجل إجبار كل الأوامر
    // بنفسها (Implementations) الحقيقية على توفير تطبيقاتها.
    // وفقًا لتغيير الأمر  false أو true ويجب أن يعيد الأسلوب إما 
    // لحالة المحرر من عدمه.
    abstract method execute()


// الأوامر الحقيقية تدخل هنا.
class CopyCommand extends Command is
    // لا يحفظ أمر النسخ في السجل نظرًا لأنه لا يغير حالة المحرر
    method execute() is
        app.clipboard = editor.getSelection()
        return false

class CutCommand extends Command is
    // يغير أمر القص حالة المحرر لذا يجب أن يُحفظ في السجل، وسيظل
    // true محفوظًا طالما أن الأسلوب يعيد
    method execute() is
        saveBackup()
        app.clipboard = editor.getSelection()
        editor.deleteSelection()
        return true

class PasteCommand extends Command is
    method execute() is
        saveBackup()
        editor.replaceSelection(app.clipboard)
        return true

// تُعد عملية التراجع أمرًا كذلك.
class UndoCommand extends Command is
    method execute() is
        app.undo()
        return false


// (Stack) السجل العام للأوامر ما هو إلا مكدَّس.
class CommandHistory is
    private field history: array of Command

    // الأخير دخولًا...
    method push(c: Command) is
        // أزح الأمر إلى نهاية مصفوفة السجل.

    // ...الأول خروجًا
    method pop():Command is
        // اجلب أحدث أمر من السجل


// فئة المحرر بها عمليات تعديل فعلية على النصوص، وتلعب دور المستقبِل:
// كل الأوامر في النهاية تفوض التنفيذ إلى أساليب المحرر
class Editor is
    field text: string

    method getSelection() is
        // أعد النص المحدَّد.

    method deleteSelection() is
        // احذف النص المحدد.

    method replaceSelection(text) is
        // في الموضع الحالي (Clipboard) أدخل محتويات الحافظة.


// The application class sets up object relations. It acts as a
// sender: when something needs to be done, it creates a command
// object and executes it.
class Application is
    field clipboard: string
    field editors: array of Editors
    field activeEditor: Editor
    field history: CommandHistory

    // The code which assigns commands to UI objects may look
    // like this.
    method createUI() is
        // ...
        copy = function() { executeCommand(
            new CopyCommand(this, activeEditor)) }
        copyButton.setCommand(copy)
        shortcuts.onKeyPress("Ctrl+C", copy)

        cut = function() { executeCommand(
            new CutCommand(this, activeEditor)) }
        cutButton.setCommand(cut)
        shortcuts.onKeyPress("Ctrl+X", cut)

        paste = function() { executeCommand(
            new PasteCommand(this, activeEditor)) }
        pasteButton.setCommand(paste)
        shortcuts.onKeyPress("Ctrl+V", paste)

        undo = function() { executeCommand(
            new UndoCommand(this, activeEditor)) }
        undoButton.setCommand(undo)
        shortcuts.onKeyPress("Ctrl+Z", undo)

    // Execute a command and check whether it has to be added to
    // the history.
    method executeCommand(command) is
        if (command.execute)
            history.push(command)

    // Take the most recent command from the history and run its
    // undo method. Note that we don't know the class of that
    // command. But we don't have to, since the command knows
    // how to undo its own action.
    method undo() is
        command = history.pop()
        if (command != null)
            command.undo()