Bridge Pattern เป็นรูปแบบการออกแบบเชิงโครงสร้าง (Structural Design Pattern) ที่แยก Abstraction และ Implementation ออกจากกัน เพื่อให้ทั้งสองฝั่งเปลี่ยนแปลงและขยายได้อย่างอิสระ โดยใช้การประกอบออบเจ็กต์ (Composition) เป็นสะพานเชื่อมระหว่างสองฝั่ง

info-bridge-pattern-with-java

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

แนวคิดสำคัญ: รายงานเป็นผู้ตัดสินใจว่า “จะแสดงข้อมูลอะไร” ส่วนตัวจัดรูปแบบเป็นผู้ตัดสินใจว่า “จะแสดงข้อมูลนั้นอย่างไร”

#1. เป้าหมายการเรียนรู้

เมื่อจบบทความ ผู้อ่านจะสามารถ

  • ระบุปัญหาที่เกิดจากการสร้างคลาสตามทุกคู่ของคุณสมบัติ
  • อธิบายบทบาท Abstraction, Refined Abstraction, Implementor และ Concrete Implementor
  • ใช้ interface และ constructor injection เพื่อประกอบสองฝั่งเข้าด้วยกัน
  • เพิ่มชนิดรายงานหรือรูปแบบการแสดงผล โดยไม่ต้องสร้างคลาสสำหรับทุกคู่
  • แยกความตั้งใจของ Bridge ออกจาก Adapter และ Strategy

พื้นฐานที่ควรมี: Java class, interface, inheritance, polymorphism และ List

เครื่องมือ: JDK 17 ขึ้นไป ไม่ต้องติดตั้งไลบรารีเพิ่มเติม ตัวอย่างนี้ใช้เฉพาะ Java Standard Library

#2. ปัญหาก่อนใช้ Bridge Pattern

สมมติว่าระบบมีชนิดรายงานสองแบบ

  • รายงานสรุป: แสดงจำนวนรายการทั้งหมด
  • รายงานละเอียด: แสดงข้อมูลแต่ละรายการ

และรองรับรูปแบบการแสดงผลสองแบบ

  • ข้อความธรรมดา (Plain Text)
  • HTML

หากสร้างคลาสเฉพาะสำหรับทุกคู่ อาจได้คลาสดังนี้

ชนิดรายงาน Plain Text HTML
รายงานสรุป SummaryTextReport SummaryHtmlReport
รายงานละเอียด DetailedTextReport DetailedHtmlReport

เมื่อเพิ่ม Markdown อาจต้องเพิ่ม SummaryMarkdownReport และ DetailedMarkdownReport อีกสองคลาส และเมื่อเพิ่มรายงานชนิดใหม่ ก็ต้องทำรูปแบบการแสดงผลทุกแบบให้รายงานนั้นด้วย

ถ้ามีชนิดรายงาน m แบบ และรูปแบบการแสดงผล n แบบ แนวทางสร้างคลาสสำหรับทุกคู่อาจต้องมีคลาสคู่ผสมถึง m × n คลาส

Bridge แยกความเปลี่ยนแปลงทั้งสองด้านออกเป็นคนละกลุ่ม จึงใช้ตัวเลือกฝั่งรายงาน m คลาส และตัวเลือกฝั่งจัดรูปแบบ n คลาส รวมเป็น m + n คลาสของตัวเลือก ตัวเลขนี้ไม่นับคลาสฐาน interface และคลาสสนับสนุน และเป็นการเปรียบเทียบกับแนวทางสร้างคลาสเฉพาะทุกคู่ ไม่ใช่สูตรนับคลาสทั้งหมดของทุกระบบ

#3. แยกสองแกนที่เปลี่ยนแปลงได้

แกน หน้าที่ ตัวอย่าง
ชนิดรายงาน เลือกเนื้อหาและระดับรายละเอียด SummaryReport, DetailedReport
รูปแบบการแสดงผล แปลงเนื้อหาให้อยู่ในรูปแบบปลายทาง PlainTextFormatter, HtmlFormatter

รายงานแต่ละชนิดถือ reference ของ ReportFormatter และเรียกใช้งานผ่านสัญญากลาง โดยไม่ต้องเลือกเองว่าตัวจัดรูปแบบเป็นคลาสใด

ความสัมพันธ์นี้เป็นแบบ has-a: Report มี ReportFormatter อยู่ภายใน ส่วนการสืบทอดยังคงใช้ภายในแต่ละฝั่งได้ เช่น SummaryReport extends Report

#4. องค์ประกอบของ Bridge Pattern

บทบาท ในตัวอย่าง ความรับผิดชอบ
Abstraction Report กำหนด API ระดับรายงาน และเก็บ reference ของตัวจัดรูปแบบ
Refined Abstraction SummaryReport, DetailedReport กำหนดว่ารายงานชนิดนั้นจะมีข้อมูลอะไร
Implementor ReportFormatter กำหนดสัญญาที่ฝั่งรายงานใช้เรียกงานจัดรูปแบบ
Concrete Implementor PlainTextFormatter, HtmlFormatter สร้างผลลัพธ์ข้อความธรรมดาหรือ HTML
Client BridgeDemo เลือกและประกอบชนิดรายงานเข้ากับรูปแบบที่ต้องการ

คำว่า Abstraction ในชื่อบทบาทของแพตเทิร์นไม่ได้หมายความว่าต้องใช้คำสั่ง abstract ของ Java เสมอไป ตัวอย่างนี้ใช้ abstract class เพราะต้องการแบ่งปัน constructor และข้อมูลพื้นฐานระหว่างรายงานสองชนิด

ส่วน Implementor เป็นบทบาทของสัญญาฝั่ง implementation จึงใช้ Java interface ได้ ไม่จำเป็นต้องเป็นคลาสที่มีโค้ดทำงานจริงทั้งหมด

#5. โครงสร้างคลาส

classDiagram
    direction TB
    class Report {
        <<abstract>>
        #ReportFormatter formatter
        #List~String~ entries
        +generate() String
    }
    class SummaryReport {
        +generate() String
    }
    class DetailedReport {
        +generate() String
    }
    class ReportFormatter {
        <<interface>>
        +format(String title, List~String~ lines) String
    }
    class PlainTextFormatter {
        +format(String title, List~String~ lines) String
    }
    class HtmlFormatter {
        +format(String title, List~String~ lines) String
    }
    Report <|-- SummaryReport
    Report <|-- DetailedReport
    Report --> ReportFormatter : delegates formatting
    ReportFormatter <|.. PlainTextFormatter
    ReportFormatter <|.. HtmlFormatter

เส้นเชื่อมจาก Report ไปยัง ReportFormatter คือจุดเชื่อมของสองฝั่ง ใน UML ใช้ association เพราะตัวจัดรูปแบบถูกส่งเข้ามาจากภายนอกและอาจใช้ร่วมกันได้ ไม่ได้ระบุการเป็นเจ้าของอายุการใช้งานแบบ UML composition แม้ในบริบทการออกแบบโค้ดจะเรียกแนวทางนี้ว่า object composition

#6. Workshop: เขียนโปรแกรมในไฟล์เดียว

สร้างโฟลเดอร์สำหรับทดลอง และบันทึกโค้ดต่อไปนี้เป็นไฟล์ BridgeDemo.java

import java.util.List;
import java.util.Objects;

public class BridgeDemo {
    public static void main(String[] args) {
        List<String> entries = List.of("Build passed", "Tests passed");
        ReportFormatter text = new PlainTextFormatter();
        ReportFormatter html = new HtmlFormatter();

        show("Summary / Text", new SummaryReport(text, entries));
        show("Summary / HTML", new SummaryReport(html, entries));
        show("Detailed / Text", new DetailedReport(text, entries));
        show("Detailed / HTML", new DetailedReport(html, entries));
    }

    private static void show(String label, Report report) {
        System.out.println("== " + label + " ==");
        System.out.println(report.generate());
        System.out.println();
    }
}

// Implementor: the contract for formatting report content.
interface ReportFormatter {
    String format(String title, List<String> lines);
}

// Concrete Implementor 1
final class PlainTextFormatter implements ReportFormatter {
    @Override
    public String format(String title, List<String> lines) {
        StringBuilder output = new StringBuilder(title);
        for (String line : lines) {
            output.append("\n- ").append(line);
        }
        return output.toString();
    }
}

// Concrete Implementor 2
final class HtmlFormatter implements ReportFormatter {
    @Override
    public String format(String title, List<String> lines) {
        StringBuilder output = new StringBuilder();
        output.append("<h1>").append(escape(title)).append("</h1>\n<ul>");
        for (String line : lines) {
            output.append("\n  <li>").append(escape(line)).append("</li>");
        }
        return output.append("\n</ul>").toString();
    }

    private static String escape(String value) {
        return value.replace("&", "&amp;")
                .replace("<", "&lt;")
                .replace(">", "&gt;")
                .replace("\"", "&quot;")
                .replace("'", "&#39;");
    }
}

// Abstraction: stores the bridge to the formatting implementation.
abstract class Report {
    protected final ReportFormatter formatter;
    protected final List<String> entries;

    protected Report(ReportFormatter formatter, List<String> entries) {
        this.formatter = Objects.requireNonNull(formatter, "formatter");
        this.entries = List.copyOf(entries);
    }

    public abstract String generate();
}

// Refined Abstraction 1: decides what the summary contains.
final class SummaryReport extends Report {
    SummaryReport(ReportFormatter formatter, List<String> entries) {
        super(formatter, entries);
    }

    @Override
    public String generate() {
        return formatter.format(
                "Summary report",
                List.of("Total items: " + entries.size()));
    }
}

// Refined Abstraction 2: decides what the detailed report contains.
final class DetailedReport extends Report {
    DetailedReport(ReportFormatter formatter, List<String> entries) {
        super(formatter, entries);
    }

    @Override
    public String generate() {
        return formatter.format("Detailed report", entries);
    }
}

ตัวอย่างเก็บหลายคลาสไว้ในไฟล์เดียวเพื่อให้ทดลองได้สะดวก โดยมี BridgeDemo เป็น public class เพียงคลาสเดียว ในโปรเจกต์จริงสามารถแยกแต่ละคลาสออกเป็นไฟล์ของตนเอง และกำหนด package กับ visibility ให้เหมาะสม

#อธิบายจุดสำคัญของโค้ด

สัญญาของฝั่ง implementation

interface ReportFormatter {
    String format(String title, List<String> lines);
}

ReportFormatter รับข้อมูลกลาง ได้แก่ชื่อรายงานและรายการข้อความ ตัวจัดรูปแบบไม่ต้องรู้ว่าข้อมูลมาจากรายงานสรุปหรือรายงานละเอียด Java interface ช่วยกำหนดสัญญาให้คลาสหลายตัวนำไป implement และให้ client เรียกผ่านชนิด interface ได้ [2]

จุดเชื่อมระหว่างสองฝั่ง

protected final ReportFormatter formatter;

ฝั่ง Report อ้างถึง interface และรับตัวจัดรูปแบบผ่าน constructor การส่ง dependency เข้ามาลักษณะนี้เรียกว่า constructor injection และไม่จำเป็นต้องใช้ framework

การแยกเนื้อหาออกจากรูปแบบ

return formatter.format(
        "Summary report",
        List.of("Total items: " + entries.size()));

SummaryReport คำนวณเนื้อหาที่จะรายงาน แล้วมอบหมายการจัดรูปแบบให้ formatter หากเปลี่ยนจาก PlainTextFormatter เป็น HtmlFormatter ก็ไม่ต้องเขียนการนับจำนวนรายการใหม่

การเก็บข้อมูลรายการ

List.copyOf(entries) สร้าง snapshot ของรายการอินพุตที่แก้ไขผ่าน list นั้นไม่ได้ และไม่ยอมรับ null list หรือ null element วิธีนี้เพียงพอกับข้อมูล String ในตัวอย่าง ไม่ใช่การ deep copy ออบเจ็กต์ทั่วไปที่อยู่ภายใน list [4]

#7. คอมไพล์และรัน

ตรวจสอบว่ามี JDK และตั้งค่า PATH แล้ว

java -version
javac -version

เข้าไปที่โฟลเดอร์ที่มี BridgeDemo.java แล้วรัน

javac -encoding UTF-8 BridgeDemo.java
java BridgeDemo

ผลลัพธ์ที่ได้

== Summary / Text ==
Summary report
- Total items: 2

== Summary / HTML ==
<h1>Summary report</h1>
<ul>
  <li>Total items: 2</li>
</ul>

== Detailed / Text ==
Detailed report
- Build passed
- Tests passed

== Detailed / HTML ==
<h1>Detailed report</h1>
<ul>
  <li>Build passed</li>
  <li>Tests passed</li>
</ul>

ผลลัพธ์ HTML ในตัวอย่างเป็น HTML fragment ที่พิมพ์ออกทาง console ยังไม่ได้เปิดเว็บเบราว์เซอร์หรือเขียนไฟล์ HTML

HtmlFormatter แปลงอักขระพิเศษก่อนใส่ลงในเนื้อหาของ <h1> และ <li> เช่น < เป็น &lt; และ & เป็น &amp; ฟังก์ชันนี้ออกแบบสำหรับบริบทข้อความ HTML ของตัวอย่าง การนำข้อมูลไปใส่ใน URL, JavaScript หรือบริบทอื่นต้องใช้การจัดการที่เหมาะกับบริบทนั้น

#8. การทำงานของโปรแกรม

  1. Client สร้าง PlainTextFormatter และ HtmlFormatter
  2. Client เลือกชนิดรายงาน และส่ง formatter ที่ต้องการผ่าน constructor
  3. Client เรียก generate() ผ่านตัวแปรชนิด Report
  4. รายงานสร้างเนื้อหาตามหน้าที่ของตนเอง
  5. รายงานเรียก formatter.format(...) เพื่อสร้างผลลัพธ์ปลายทาง

ทั้งสี่คู่ทำงานได้จากคลาสรายงานสองชนิดและตัวจัดรูปแบบสองชนิด โดยไม่มี SummaryHtmlReport หรือ DetailedTextReport

#9. ขยายระบบ: เพิ่ม Markdown

เพิ่มคลาสต่อไปนี้ที่ท้ายไฟล์ BridgeDemo.java โดยอยู่นอกคลาสอื่น

final class MarkdownFormatter implements ReportFormatter {
    @Override
    public String format(String title, List<String> lines) {
        StringBuilder output = new StringBuilder("# ").append(title);
        for (String line : lines) {
            output.append("\n- ").append(line);
        }
        return output.toString();
    }
}

จากนั้นเพิ่มโค้ดต่อไปนี้ภายใน main()

Report markdownReport = new DetailedReport(
        new MarkdownFormatter(), entries);
System.out.println(markdownReport.generate());

จะได้ผลลัพธ์

# Detailed report
- Build passed
- Tests passed

MarkdownFormatter ใช้กับ SummaryReport ได้เช่นกัน โดยไม่ต้องแก้คลาสรายงานทั้งสอง ตัว formatter แบบง่ายนี้ถือว่าข้อความตัวอย่างเป็นข้อมูลที่เชื่อถือได้และไม่มี Markdown พิเศษ หากต้องการให้ข้อมูลภายนอกแสดงเป็นข้อความล้วน ต้องจัดการ syntax และ HTML ตามข้อกำหนดของตัว renderer ที่ใช้งาน

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

#10. ขยายอีกฝั่ง: เพิ่มรายงานรายการแรก

เพิ่มคลาสต่อไปนี้ที่ท้ายไฟล์

final class FirstItemReport extends Report {
    FirstItemReport(ReportFormatter formatter, List<String> entries) {
        super(formatter, entries);
    }

    @Override
    public String generate() {
        List<String> lines = entries.isEmpty()
                ? List.of("No items")
                : List.of(entries.get(0));
        return formatter.format("First item report", lines);
    }
}

เรียกใช้งานจาก main()

Report firstItem = new FirstItemReport(new HtmlFormatter(), entries);
System.out.println(firstItem.generate());

รายงานชนิดใหม่นี้ใช้ formatter เดิมได้ทุกตัว เพราะยังส่งข้อมูลตามสัญญา format(String, List<String>) ที่ตกลงกันไว้

ขอบเขตของความอิสระ: หากเพิ่มความต้องการใหม่ เช่น ตารางหลายคอลัมน์ รูปภาพ หรือแผนภูมิ สัญญาปัจจุบันอาจไม่เพียงพอและอาจต้องเปลี่ยนทั้งสองฝั่ง Bridge จึงต้องอาศัยสัญญากลางที่เหมาะสม ไม่ได้รับประกันว่าทุกการเปลี่ยนแปลงจะเป็นอิสระตลอดไป

#11. เปรียบเทียบ Bridge, Adapter และ Strategy

ประเด็น Bridge Adapter Strategy
ความตั้งใจหลัก แยก abstraction และ implementation ให้ขยายได้อิสระ ทำให้อินเทอร์เฟซที่มีอยู่ใช้งานร่วมกันได้ แยกอัลกอริทึมหรือวิธีทำงานที่ทดแทนกันได้
ปัญหาที่เน้น สองแกนของความเปลี่ยนแปลงและคลาสคู่ผสม ความเข้ากันไม่ได้ของสัญญาเดิม การเลือกวิธีทำงานหนึ่งจากหลายวิธี
ตัวอย่าง ชนิดรายงาน × รูปแบบการแสดงผล ห่อไลบรารีส่งอีเมลเดิมให้ตรงกับ interface ใหม่ เลือกวิธีเรียงข้อมูลหรือคำนวณส่วนลด
ลักษณะที่อาจพบในโค้ด ออบเจ็กต์ระดับสูงถือ reference ของ implementation ออบเจ็กต์ห่อระบบเดิมและแปลงการเรียก Context ถือ reference ของ strategy

Bridge และ Strategy อาจมีโครงสร้างโค้ดใกล้เคียงกัน การเห็น constructor รับ interface เพียงอย่างเดียวจึงยังไม่พอจะตัดสินชื่อแพตเทิร์น ในตัวอย่างนี้มองเป็น Bridge เพราะออกแบบให้ ตระกูลรายงาน และ ตระกูลตัวจัดรูปแบบ ขยายแยกจากกัน ส่วนเมื่อมองเฉพาะการเลือก formatter ก็อาจอธิบายในมุม Strategy ได้เช่นกัน ความตั้งใจในการออกแบบเป็นสิ่งที่ช่วยแยกแพตเทิร์น [1]

#12. ควรใช้เมื่อใด

  • มีความเปลี่ยนแปลงอย่างน้อยสองด้านที่แยกจากกันได้ และต้องนำมาจับคู่กันหลายแบบ
  • เริ่มมีชื่อคลาสที่เกิดจากการผสมคุณสมบัติ เช่น SummaryHtmlReport และ DetailedHtmlReport
  • ต้องการเพิ่ม implementation โดยลดผลกระทบต่อโค้ดระดับสูง
  • ต้องการทดสอบ logic ระดับรายงานแยกจากการจัดรูปแบบปลายทาง

หากระบบมีรายงานเพียงชนิดเดียว รูปแบบเดียว และยังไม่มีเหตุผลรองรับการขยาย การแยก hierarchy สองฝั่งอาจเพิ่มภาระการอ่านโค้ด ควรเริ่มจากโครงสร้างที่พอดีกับความต้องการจริง

#13. ประโยชน์และสิ่งที่ต้องแลก

ประโยชน์ สิ่งที่ต้องพิจารณา
ลดการสร้างคลาสเฉพาะทุกคู่ มี interface และชั้นการมอบหมายงานเพิ่มขึ้น
แยกเหตุผลที่แต่ละส่วนต้องเปลี่ยน ต้องออกแบบข้อมูลกลางและสัญญาให้เหมาะสม
ทดสอบเนื้อหารายงานแยกจากการจัดรูปแบบได้ ยังต้องทดสอบคู่สำคัญเมื่อนำสองฝั่งมาทำงานร่วมกัน
เพิ่ม implementation ใหม่ได้โดยไม่แก้รายงานเดิม Client หรือ configuration ยังต้องเลือก implementation

#14. ข้อผิดพลาดที่พบบ่อย

ใช้ instanceof เลือกรูปแบบในรายงาน — ถ้า SummaryReport ต้องตรวจว่า formatter เป็น HtmlFormatter หรือไม่ แสดงว่า logic ระดับรายงานกลับไปผูกกับ implementation เฉพาะอีกครั้ง

สร้าง interface แล้วคิดว่าเป็น Bridge ทันที — interface เป็นกลไกของภาษา ส่วน Bridge เป็นการจัดวางความรับผิดชอบเพื่อรองรับสองแกนที่ขยายได้อิสระ

กล่าวว่า Bridge ห้าม inheritance — ตัวอย่างยังใช้ inheritance ภายในตระกูลรายงาน แต่ใช้การประกอบออบเจ็กต์เพื่อเชื่อมข้ามสองตระกูล

เข้าใจว่าต้องเปลี่ยน implementation บนออบเจ็กต์เดิมขณะรันได้เสมอ — ตัวอย่างใช้ final field และเลือก formatter ตอนสร้างรายงาน หากต้องการเปลี่ยนรูปแบบ ให้สร้างรายงานใหม่ด้วย formatter ตัวอื่น Bridge ไม่ได้บังคับให้มี setter

ให้ formatter ตัดสินใจเนื้อหาธุรกิจ — การนับจำนวนรายการควรอยู่ฝั่งรายงาน ส่วน formatter ควรรับเนื้อหาที่พร้อมแสดงแล้ว

#15. กิจกรรมทดลองและเกณฑ์ตรวจสอบ

  1. รันตัวอย่างเดิม และตรวจว่ามีผลลัพธ์ครบทั้งสี่คู่
  2. เพิ่ม MarkdownFormatter และใช้กับรายงานทั้งสองชนิด โดยไม่แก้โค้ดภายในรายงานเดิม
  3. เพิ่ม FirstItemReport และใช้กับ formatter เดิมทั้งสามตัว
  4. เปลี่ยนข้อมูลเป็น List.of() แล้วตรวจว่ารายงานสรุปแสดง Total items: 0 และรายงานรายการแรกแสดง No items
  5. เปลี่ยนข้อมูลเป็น List.of("R&D <core>") แล้วตรวจว่า HTML ของรายงานละเอียดมี R&amp;D &lt;core&gt;
  6. อธิบายว่าหากเพิ่มเนื้อหาเป็นตารางหลายคอลัมน์ จะต้องประเมินสัญญา ReportFormatter ใหม่อย่างไร

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

#16. ข้อคิดสำหรับการนำไปใช้

จุดที่ควรมองหาในระบบจริงคือ “อะไรสองอย่างที่ต้องเปลี่ยนแยกกัน แต่ถูกเขียนรวมไว้ในคลาสเดียว” หากแบ่งหน้าที่และกำหนดสัญญากลางได้ดี Bridge จะช่วยให้แต่ละฝั่งเติบโตได้โดยไม่เพิ่มคลาสตามทุกคู่ของคุณสมบัติ

ในตัวอย่างนี้ ความสัมพันธ์หลักคือ Report มี ReportFormatter และมอบหมายการแสดงผลผ่านสัญญาเดียวกัน ทำให้ SummaryReport และ DetailedReport เลือกใช้งาน Plain Text, HTML หรือ Markdown ได้จากการประกอบออบเจ็กต์

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

  1. Gamma, E., Helm, R., Johnson, R., & Vlissides, J. (1994). Design Patterns: Elements of Reusable Object-Oriented Software. Addison-Wesley. หัวข้อ Bridge, Adapter และ Strategy — แหล่งอ้างอิงหลักด้านความตั้งใจและโครงสร้างของแพตเทิร์น
  2. Dev.java — Interfaces — การกำหนดและใช้งาน Java interface
  3. Dev.java — Inheritance — พื้นฐาน inheritance และ polymorphism
  4. Java SE 17 API — List.copyOf — คุณสมบัติของ list ที่สร้างจาก copyOf