ใช้ตัวกลางประสานงานระหว่างออบเจ็กต์ เพื่อให้แต่ละคอมโพเนนต์ไม่ต้องรู้จักรายละเอียดของคอมโพเนนต์อื่น

ประเภท: Behavioral Design Pattern
ตัวอย่างหลัก: ฟอร์มลงทะเบียน
พื้นฐานที่ควรมี: Java class, interface, inheritance และ enum

#1. Mediator Pattern คืออะไร

Mediator Pattern เป็นรูปแบบการออกแบบที่ย้ายกติกาการทำงานร่วมกันของออบเจ็กต์หลายตัวมาไว้ในออบเจ็กต์ตัวกลางที่เรียกว่า Mediator เมื่อคอมโพเนนต์เกิดเหตุการณ์ เช่น ผู้ใช้กรอกข้อมูลหรือกดปุ่ม คอมโพเนนต์จะแจ้งเหตุการณ์ให้ตัวกลางตัดสินใจว่าจะให้ส่วนใดทำอะไรต่อ

คอมโพเนนต์ยังมีสถานะและพฤติกรรมของตัวเอง แต่ไม่ต้องอ้างอิงคอมโพเนนต์อื่นเพื่อประสานงานกันโดยตรง ส่วน Mediator จะรู้จักคอมโพเนนต์ที่เกี่ยวข้องและควบคุมปฏิสัมพันธ์ภายในขอบเขตที่รับผิดชอบ

ลองนึกถึงหอบังคับการบิน เครื่องบินแต่ละลำรายงานข้อมูลต่อหอบังคับการบิน แล้วรับคำแนะนำกลับมา หอบังคับการบินทำหน้าที่ประสานงานตามสถานการณ์ ภาพเปรียบเทียบนี้ช่วยให้เห็นบทบาทของตัวกลาง ส่วนตัวอย่าง Java ต่อไปนี้จะใช้ฟอร์มที่เรียบง่ายกว่า

#2. ปัญหาที่ต้องการแก้

สมมติว่าฟอร์มลงทะเบียนมี 3 ส่วน ได้แก่

  • ช่องกรอกอีเมล TextField
  • ช่องยอมรับเงื่อนไข CheckBox
  • ปุ่มลงทะเบียน Button

ปุ่มควรเปิดใช้งานเมื่ออีเมลผ่านกติกาตรวจสอบและผู้ใช้ยอมรับเงื่อนไขแล้ว หากช่องอีเมลต้องอ่านสถานะของ CheckBox และปรับ Button เอง ขณะเดียวกัน CheckBox ก็ต้องอ่านอีเมลและปรับ Button เช่นกัน กติกาเดียวกันจะกระจายอยู่หลายจุด

เมื่อเพิ่มเงื่อนไขใหม่ เช่น ต้องเลือกหลักสูตรก่อนลงทะเบียน การแก้ไขอาจกระทบหลายคลาส และคอมโพเนนต์ที่ควรใช้ซ้ำได้ก็เริ่มผูกติดกับฟอร์มนี้

ประเด็น เมื่อคอมโพเนนต์ประสานงานกันเอง เมื่อใช้ Mediator
ผู้ตัดสินใจเปิดปุ่ม กระจายอยู่ในช่องอีเมลและ CheckBox RegistrationDialog
สิ่งที่ TextField ต้องรู้ คอมโพเนนต์อื่นและกติกาของฟอร์ม วิธีแจ้งเหตุการณ์ผ่าน Mediator
การเปลี่ยนกติกาประสานงาน อาจแก้หลายคอมโพเนนต์ แก้ในตัวกลางหรือ policy ที่ตัวกลางเรียกใช้
ภาระที่เพิ่มขึ้น ความสัมพันธ์ระหว่างคอมโพเนนต์ ต้องดูแลตัวกลางและสัญญาของเหตุการณ์

Mediator ไม่ได้ทำให้ทุก dependency หายไป แต่เปลี่ยนจากการพึ่งพาระหว่างคอมโพเนนต์ไปเป็นการพึ่งพาสัญญาของตัวกลาง

#3. บทบาทใน Pattern

บทบาท หน้าที่ คลาสในตัวอย่าง
Mediator กำหนดวิธีรับเหตุการณ์จากคอมโพเนนต์ Mediator interface
Concrete Mediator รวมกติกาการประสานงานและสั่งคอมโพเนนต์ RegistrationDialog
Colleague / Component เก็บสถานะและพฤติกรรมพื้นฐาน พร้อมอ้างอิงตัวกลาง Component
Concrete Colleagues สร้างเหตุการณ์และทำงานเฉพาะส่วน TextField, CheckBox, Button

ชื่อคลาสไม่ใช่ตัวตัดสินว่าเป็น Mediator หรือไม่ ให้พิจารณาว่ากติกาการทำงานร่วมกันอยู่ที่ใด และคอมโพเนนต์ยังต้องเรียกหากันโดยตรงหรือไม่

#4. ลำดับการทำงาน

แผนภาพนี้แสดงเหตุการณ์หลัก เมื่อเริ่มจากยังไม่ยอมรับเงื่อนไข แล้วกรอกอีเมลที่ผ่านกติกา จากนั้นยอมรับเงื่อนไขและกดปุ่ม

sequenceDiagram
    participant E as TextField
    participant M as RegistrationDialog
    participant C as CheckBox
    participant B as Button
    E->>M: onEvent(CHANGED)
    Note over M: อีเมลผ่าน แต่ยังไม่ยอมรับเงื่อนไข
    M->>B: setEnabled(false)
    C->>M: onEvent(CHANGED)
    Note over M: อีเมลผ่านและยอมรับเงื่อนไขแล้ว
    M->>B: setEnabled(true)
    B->>M: onEvent(CLICKED)
    Note over M: ตรวจเงื่อนไขอีกครั้งและจำลองการลงทะเบียน

TextField และ CheckBox แจ้งเหตุการณ์ แต่ไม่ได้สั่ง Button เอง การสั่งเปิดหรือปิดปุ่มเป็นความรับผิดชอบของ RegistrationDialog

#5. Workshop: ฟอร์มลงทะเบียนด้วย Java

#เป้าหมาย

สร้างตัวอย่างที่รันจาก Terminal ได้ โดยจำลองคอมโพเนนต์ของฟอร์ม ไม่ต้องเปิดหน้าต่าง GUI เพื่อให้เห็นโครงสร้างของ Pattern ชัดเจน

กติกาคือ เปิดปุ่ม = อีเมลผ่านกติกาตัวอย่าง AND ยอมรับเงื่อนไข

สถานะอีเมล ยอมรับเงื่อนไข ปุ่มลงทะเบียน
ว่าง ไม่ยอมรับ ปิดใช้งาน
ไม่ผ่านกติกา ยอมรับ ปิดใช้งาน
ผ่านกติกา ไม่ยอมรับ ปิดใช้งาน
ผ่านกติกา ยอมรับ เปิดใช้งาน

การตรวจอีเมลในตัวอย่างใช้ regular expression อย่างง่ายเพื่อประกอบการสอน ไม่ครอบคลุมรูปแบบอีเมลทั้งหมดและไม่ยืนยันว่าผู้ใช้เป็นเจ้าของอีเมลนั้น

#ขั้นตอนที่ 1: สร้างไฟล์

สร้างไฟล์ชื่อ MediatorDemo.java แล้วคัดลอกโค้ดทั้งหมดต่อไปนี้ลงไป คลาสสนับสนุนถูกประกาศเป็น nested classes เพื่อให้ตัวอย่างอยู่ในไฟล์เดียว ในโครงการจริงสามารถแยกไฟล์ตามความเหมาะสม

import java.util.Objects;

public class MediatorDemo {
    public static void main(String[] args) {
        RegistrationDialog dialog = new RegistrationDialog();

        showState("1) Initial", dialog);
        dialog.registerButton().click();

        dialog.emailField().setText("student");
        dialog.termsCheckbox().setChecked(true);
        showState("2) Invalid email + accepted terms", dialog);
        dialog.registerButton().click();

        dialog.emailField().setText("student@npru.ac.th");
        showState("3) Valid email + accepted terms", dialog);
        dialog.registerButton().click();

        dialog.termsCheckbox().setChecked(false);
        showState("4) Consent withdrawn", dialog);
        dialog.registerButton().click();
    }

    private static void showState(String label, RegistrationDialog dialog) {
        System.out.println(label + " -> enabled="
                + dialog.registerButton().isEnabled());
    }

    enum UiEvent {
        CHANGED, CLICKED
    }

    interface Mediator {
        void onEvent(Component sender, UiEvent event);
    }

    abstract static class Component {
        protected final Mediator mediator;

        Component(Mediator mediator) {
            this.mediator = Objects.requireNonNull(mediator);
        }
    }

    static final class TextField extends Component {
        private String text = "";

        TextField(Mediator mediator) {
            super(mediator);
        }

        String getText() {
            return text;
        }

        void setText(String text) {
            this.text = Objects.requireNonNull(text).strip();
            mediator.onEvent(this, UiEvent.CHANGED);
        }
    }

    static final class CheckBox extends Component {
        private boolean checked;

        CheckBox(Mediator mediator) {
            super(mediator);
        }

        boolean isChecked() {
            return checked;
        }

        void setChecked(boolean checked) {
            this.checked = checked;
            mediator.onEvent(this, UiEvent.CHANGED);
        }
    }

    static final class Button extends Component {
        private boolean enabled;

        Button(Mediator mediator) {
            super(mediator);
        }

        boolean isEnabled() {
            return enabled;
        }

        void setEnabled(boolean enabled) {
            this.enabled = enabled;
        }

        void click() {
            if (!enabled) {
                System.out.println("Registration disabled.");
                return;
            }
            mediator.onEvent(this, UiEvent.CLICKED);
        }
    }

    static final class RegistrationDialog implements Mediator {
        private final TextField email;
        private final CheckBox terms;
        private final Button register;

        RegistrationDialog() {
            email = new TextField(this);
            terms = new CheckBox(this);
            register = new Button(this);
            refreshButton();
        }

        TextField emailField() {
            return email;
        }

        CheckBox termsCheckbox() {
            return terms;
        }

        Button registerButton() {
            return register;
        }

        @Override
        public void onEvent(Component sender, UiEvent event) {
            if (event == UiEvent.CHANGED
                    && (sender == email || sender == terms)) {
                refreshButton();
            } else if (event == UiEvent.CLICKED && sender == register) {
                if (canRegister()) {
                    System.out.println("Registered: " + email.getText());
                }
            }
        }

        private void refreshButton() {
            register.setEnabled(canRegister());
        }

        private boolean canRegister() {
            // A small teaching rule, not complete email validation.
            boolean emailLooksValid = email.getText()
                    .matches("[^\\s@]+@[^\\s@]+\\.[^\\s@]+");
            return emailLooksValid && terms.isChecked();
        }
    }
}

#ขั้นตอนที่ 2: รันโปรแกรม

ตรวจสอบว่ามี JDK 17 ขึ้นไป

java -version
javac -version

คอมไพล์และรันจากโฟลเดอร์ที่เก็บไฟล์

javac -encoding UTF-8 MediatorDemo.java
java MediatorDemo

หรือรันไฟล์ source โดยตรงด้วย JDK ที่รองรับ source-file mode

java MediatorDemo.java

#ขั้นตอนที่ 3: ตรวจผลลัพธ์

1) Initial -> enabled=false
Registration disabled.
2) Invalid email + accepted terms -> enabled=false
Registration disabled.
3) Valid email + accepted terms -> enabled=true
Registered: student@npru.ac.th
4) Consent withdrawn -> enabled=false
Registration disabled.

ข้อความ Registered เป็นผลลัพธ์จำลองของ Workshop หากนำไปทำระบบจริงให้เรียก application service สำหรับบันทึกข้อมูลและตรวจสอบกฎฝั่งเซิร์ฟเวอร์

#6. อ่านโค้ดทีละส่วน

#6.1 Mediator interface เป็นสัญญาการสื่อสาร

interface Mediator {
    void onEvent(Component sender, UiEvent event);
}

sender ระบุคอมโพเนนต์ที่ส่งเหตุการณ์ ส่วน event ระบุชนิดเหตุการณ์ คอมโพเนนต์รู้จัก interface นี้ โดยไม่ต้องทราบว่าตัวกลางใช้กติกาใดหรือจะสั่งคอมโพเนนต์ตัวไหนต่อ

ตัวอย่างตั้งชื่อเมธอดว่า onEvent เพื่อสื่อว่าเป็น callback ของเหตุการณ์ และใช้ enum เพื่อลดการพิมพ์ชื่อเหตุการณ์ผิดแบบที่อาจเกิดเมื่อใช้ String

#6.2 คอมโพเนนต์เก็บสถานะแล้วแจ้งตัวกลาง

void setChecked(boolean checked) {
    this.checked = checked;
    mediator.onEvent(this, UiEvent.CHANGED);
}

CheckBox ไม่ได้อ้างอิง TextField หรือ Button เลย จึงไม่ต้องทราบว่าการเปลี่ยนค่าของตัวเองจะมีผลต่อคอมโพเนนต์ใดบ้าง

TextField ใช้ Objects.requireNonNull ป้องกันค่า null และใช้ String.strip() ตัด whitespace ที่หัวและท้ายข้อความ API เหล่านี้มีคำอธิบายในเอกสาร Java ทางการ [2][3]

#6.3 Concrete Mediator ตัดสินใจกติกาการประสานงาน

private void refreshButton() {
    register.setEnabled(canRegister());
}

เมื่อช่องอีเมลหรือ CheckBox เปลี่ยนค่า ตัวกลางจะอ่านสถานะปัจจุบันแล้วคำนวณว่าควรเปิดปุ่มหรือไม่ วิธีนี้ทำให้ทั้งสองเหตุการณ์ใช้กติกาเดียวกัน

onEvent ตรวจ sender == email และ sender == terms เพื่อยอมรับเหตุการณ์จากคอมโพเนนต์ของฟอร์มนี้โดยเฉพาะ

#6.4 การอัปเดตจากตัวกลางไม่ควรสร้างวงจรเหตุการณ์โดยไม่ตั้งใจ

Button.setEnabled() เปลี่ยนสถานะอย่างเดียวและไม่แจ้ง CHANGED กลับไป จึงไม่เกิดวงจรที่ตัวกลางสั่งปุ่มแล้วปุ่มแจ้งกลับให้ตัวกลางสั่งซ้ำ

ใน GUI จริง API บางตัวอาจปล่อย event เมื่อเปลี่ยนค่าด้วยโปรแกรม ควรแยกการเปลี่ยนจากผู้ใช้กับการอัปเดตภายใน หรือใช้กลไกป้องกันการเข้าซ้ำตามความเหมาะสม

#6.5 ตรวจเงื่อนไขก่อนดำเนินการ

Button ป้องกันการคลิกเมื่อปิดใช้งาน ส่วนตัวกลางตรวจ canRegister() อีกครั้งก่อนทำงาน วิธีนี้ช่วยให้เงื่อนไขการดำเนินการชัดเจน แต่สำหรับระบบเว็บ การปิดปุ่มฝั่งหน้าจอไม่สามารถทดแทน validation ฝั่งเซิร์ฟเวอร์ได้

#7. ตรวจสอบพฤติกรรมของ Workshop

โค้ดฉบับนี้ผ่านการคอมไพล์และรันด้วย OpenJDK 17.0.20 และตรวจผลลัพธ์ตาม 4 สถานการณ์ใน main() ได้แก่

  1. เริ่มต้นโดยยังไม่มีข้อมูล ปุ่มต้องปิดใช้งาน
  2. อีเมล student ไม่ผ่านกติกา แม้ยอมรับเงื่อนไขแล้วปุ่มยังต้องปิดใช้งาน
  3. เปลี่ยนเป็น student@npru.ac.th พร้อมยอมรับเงื่อนไข ปุ่มเปิดใช้งานและแสดงผลลงทะเบียนได้
  4. ถอนการยอมรับเงื่อนไข ปุ่มกลับไปปิดใช้งาน

เมื่อนำตัวอย่างไปต่อยอด ควรเพิ่มการตรวจสอบกรณีล้างอีเมลหลังเปิดปุ่ม การยอมรับเงื่อนไขก่อนกรอกอีเมล และการสลับสถานะซ้ำ เพื่อยืนยันว่าตัวกลางคำนวณจากสถานะปัจจุบันเสมอ

#8. ข้อดีและข้อจำกัด

ประเด็น ผลต่อการออกแบบ
ลด coupling ระหว่างคอมโพเนนต์ TextField ไม่ต้องอ้างอิง CheckBox หรือ Button
รวมกติกาการประสานงาน ติดตามสาเหตุของการเปิดหรือปิดปุ่มได้จากตัวกลาง
ใช้คอมโพเนนต์ซ้ำ เปลี่ยนตัวกลางเพื่อใช้กติกาการทำงานร่วมกันแบบอื่นได้
เพิ่มโครงสร้าง มี interface, event และคลาสตัวกลางเพิ่มขึ้น
เสี่ยงต่อ God Object ตัวกลางอาจโตเกินไปหากรวมทุก use case และทุกกฎธุรกิจไว้ด้วยกัน

เมื่อระบบเริ่มซับซ้อน ให้แยก Mediator ตามขอบเขตงาน เช่น ฟอร์มลงทะเบียนและฟอร์มแก้ไขโปรไฟล์ ส่วนการตรวจคุณสมบัติหรือบันทึกข้อมูลควรแยกเป็น policy หรือ service ที่ทดสอบได้อิสระ แล้วให้ Mediator เรียกใช้

Mediator ไม่ได้ให้ความสามารถด้าน asynchronous execution, transaction หรือ thread safety โดยอัตโนมัติ ตัวอย่างนี้ทำงานแบบ synchronous บนเธรดเดียว

#9. เหมาะกับสถานการณ์ใด

ใช้เมื่อมีคอมโพเนนต์หลายตัวที่มีปฏิสัมพันธ์ซับซ้อน เช่น ฟอร์มที่หลายช่องควบคุมสถานะกัน หน้าจอตั้งค่าที่เปิดหรือปิดตัวเลือกตามเงื่อนไข หรือกลุ่มออบเจ็กต์ที่ต้องทำงานตามกติกาประสานงานร่วมกัน

หากมีเพียงออบเจ็กต์หนึ่งเรียก service อีกตัวโดยตรง และความสัมพันธ์ยังเข้าใจง่าย การเพิ่ม Mediator อาจเพิ่มโครงสร้างโดยยังไม่เกิดประโยชน์ชัดเจน

#10. ต่างจาก Pattern อื่นอย่างไร

Pattern จุดประสงค์หลัก คำถามที่ช่วยแยกบทบาท
Mediator รวมกติกาปฏิสัมพันธ์ระหว่างคอมโพเนนต์ เมื่อ A เปลี่ยน B และ C ควรทำอะไรต่อ
Observer แจ้งผู้ติดตามเมื่อเกิดเหตุการณ์หรือสถานะเปลี่ยน ใครสมัครรับการเปลี่ยนแปลงนี้ไว้บ้าง
Facade ทำทางเข้าใช้งาน subsystem ให้เรียบง่าย ผู้เรียกจะใช้งานระบบย่อยผ่าน API ที่ง่ายขึ้นได้อย่างไร
Command ห่อคำขอเป็นออบเจ็กต์ จะเก็บ จัดคิว หรือส่งต่อคำสั่งอย่างไร

Mediator สามารถใช้ Observer เป็นกลไกรับเหตุการณ์จากคอมโพเนนต์ได้ ทั้งสอง Pattern จึงใช้ร่วมกันได้ จุดที่ต้องดูคือใครเป็นผู้ถือกติกาการทำงานร่วมกัน การส่ง event ผ่านตัวกลางอย่างเดียวไม่ได้ทำให้ทุกระบบเป็น Mediator เสมอไป

#11. ตัวอย่างแนวคิดจาก Java Swing

เอกสาร Java อธิบายว่า ButtonGroup ใช้จัดกลุ่มปุ่มที่มีสถานะเลือกได้ เมื่อเลือกปุ่มหนึ่ง ปุ่มอื่นในกลุ่มจะถูกยกเลิกการเลือก [4]

import javax.swing.ButtonGroup;
import javax.swing.JRadioButton;

JRadioButton online = new JRadioButton("Online");
JRadioButton onsite = new JRadioButton("Onsite");

ButtonGroup group = new ButtonGroup();
group.add(online);
group.add(onsite);

ในเชิงการออกแบบ สามารถมอง ButtonGroup เป็นตัวอย่างของการรวมกติกาการเลือกไว้ที่ตัวกลาง ปุ่มแต่ละตัวไม่ต้องไล่ยกเลิกการเลือกปุ่มอื่นด้วยตัวเอง นี่เป็นการตีความเพื่อเชื่อมกับบทเรียน โดยเอกสาร Java ที่อ้างอิงอธิบายพฤติกรรมของ API ไม่ได้ระบุชื่อ GoF Mediator โดยตรง

โค้ด Swing ส่วนนี้เป็นตัวอย่างการจัดกลุ่มปุ่ม ยังต้องเพิ่มปุ่มลงในหน้าต่างหากต้องการแสดง GUI และ ButtonGroup ไม่ได้ทำหน้าที่จัดวางองค์ประกอบบนหน้าจอ

#12. แบบฝึกหัดต่อยอด

#แบบฝึกหัดที่ 1: เพิ่มการเลือกหลักสูตร

เพิ่มคอมโพเนนต์ CourseSelector และกำหนดว่าปุ่มเปิดได้เมื่อเลือกหลักสูตรแล้ว พร้อมกับอีเมลผ่านกติกาและยอมรับเงื่อนไข คอมโพเนนต์เดิมควรยังคงแจ้งเหตุการณ์ผ่าน Mediator โดยไม่อ้างอิงตัวเลือกหลักสูตร

เกณฑ์ตรวจ: การล้างค่าหลักสูตรทำให้ปุ่มปิด และการเลือกหลักสูตรกลับทำให้ปุ่มเปิดเมื่อเงื่อนไขอื่นครบ

#แบบฝึกหัดที่ 2: แยกกฎออกจากตัวกลาง

สร้าง RegistrationPolicy สำหรับตรวจความพร้อมลงทะเบียน แล้วให้ RegistrationDialog รับผิดชอบการประสานงาน ส่วน policy รับผิดชอบการตัดสินตามข้อมูล

เกณฑ์ตรวจ: ทดสอบ policy ได้โดยไม่ต้องสร้าง Button หรือ TextField และพฤติกรรมของฟอร์มยังคงเดิม

#แบบฝึกหัดที่ 3: ใช้กับ GUI

เปลี่ยนจากคอมโพเนนต์จำลองเป็น Swing หรือ JavaFX โดยให้ callback ของ UI ส่งเหตุการณ์ไปยังตัวกลาง และระวัง event ที่เกิดจากการอัปเดตค่าด้วยโปรแกรม

เกณฑ์ตรวจ: การเปลี่ยนอีเมลและเงื่อนไขทำให้สถานะปุ่มเปลี่ยนตามกติกา และการอัปเดตจากตัวกลางไม่ทำให้เกิดวงจร event

#13. ทบทวนแนวคิด

หัวใจของ Mediator Pattern คือการมีเจ้าของกติกาการทำงานร่วมกันที่ชัดเจน คอมโพเนนต์ดูแลหน้าที่ของตนเองและแจ้งเหตุการณ์ ส่วนตัวกลางเป็นผู้เชื่อมสถานะกับการกระทำของคอมโพเนนต์อื่น

ในตัวอย่างนี้ TextField ดูแลข้อความ CheckBox ดูแลสถานะยอมรับเงื่อนไข Button ดูแลสถานะเปิดใช้งาน ส่วน RegistrationDialog ดูแลความสัมพันธ์ระหว่างทั้งสามส่วน

#เอกสารอ้างอิง

  1. Gamma, E., Helm, R., Johnson, R., & Vlissides, J. Design Patterns: Elements of Reusable Object-Oriented Software. Addison-Wesley, 1995. ข้อมูลหนังสือจากสำนักพิมพ์
  2. Oracle. Objects — Java SE 21 API — การใช้งาน requireNonNull.
  3. Oracle. String — Java SE 21 API — การใช้งาน strip และ matches.
  4. Oracle. ButtonGroup — Java SE 21 API — กติกาการเลือกปุ่มภายในกลุ่ม.

โค้ด Workshop เป็นตัวอย่างที่จัดทำขึ้นสำหรับบทความนี้ โดยใช้แนวคิดจาก Mediator Pattern ไม่ได้คัดลอกโค้ดตัวอย่างจากหนังสือ