คลาสแม่กำหนดโครงของอัลกอริทึม และเปิดให้คลาสลูกกำหนดรายละเอียดของบางขั้นตอน

หมวดหมู่: Behavioral Design Pattern
ตัวอย่าง: ระบบสร้างรายงานข้อความและ HTML
พื้นฐาน: Java, inheritance, abstract class, method overriding และ polymorphism

#1. Template Method Pattern คืออะไร

info-template-method-pattern-with-java

Template Method Pattern เป็นรูปแบบการออกแบบเชิงพฤติกรรมที่กำหนด โครงและลำดับของอัลกอริทึมไว้ในเมธอดของคลาสแม่ แล้วเปิดให้คลาสลูกกำหนดรายละเอียดบางขั้นตอนผ่านการ override โดยใช้ลำดับหลักร่วมกัน

จุดสำคัญอยู่ที่การแยกสองสิ่งนี้ออกจากกัน:

  • โครงของงาน: ขั้นตอนใดเกิดก่อน ขั้นตอนใดเกิดหลัง และขั้นตอนใดต้องใช้ร่วมกัน
  • รายละเอียดของแต่ละขั้นตอน: คลาสลูกทำขั้นตอนนั้นอย่างไร

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

คำว่า Template ในที่นี้หมายถึงโครงของอัลกอริทึม ไม่ใช่ไฟล์แม่แบบ HTML และไม่ใช่ generic type ของ Java

#2. ปัญหาก่อนใช้แพตเทิร์น

สมมติว่า PlainTextReport และ HtmlReport ต่างมีเมธอด generate() ของตนเอง แต่ละคลาสจึงต้องเขียนการตรวจข้อมูลและลำดับการประกอบรายงานซ้ำ

เมื่อเพิ่มกฎใหม่ เช่น ห้ามสร้างรายงานจากรายการว่าง นักพัฒนาต้องแก้หลายแห่ง หากแก้ไม่ครบ รายงานแต่ละชนิดอาจตรวจข้อมูลต่างกันหรือประกอบส่วนต่าง ๆ คนละลำดับ

Template Method ช่วยย้ายส่วนที่ใช้ร่วมกันไปยังคลาสแม่ ส่วนคลาสลูกเก็บเฉพาะรายละเอียดที่แตกต่างกัน

อย่างไรก็ตาม การมี abstract class เพียงอย่างเดียวยังไม่เพียงพอที่จะเรียกว่า Template Method ต้องมีเมธอดที่กำหนดโครงอัลกอริทึม และเรียกขั้นตอนที่คลาสลูกสามารถกำหนดรายละเอียดได้ด้วย

#3. องค์ประกอบสำคัญ

องค์ประกอบ หน้าที่ ตัวอย่างในบทความ
Abstract Class เก็บโครงอัลกอริทึมและโค้ดที่ใช้ร่วมกัน ReportGenerator
Template Method เรียกขั้นตอนตามลำดับที่กำหนด generate()
Abstract Step ขั้นตอนที่ concrete subclass ต้องกำหนด renderHeader(), renderBody(), renderFooter()
Concrete Step ขั้นตอนที่คลาสแม่มี implementation ให้แล้ว validate()
Hook จุดขยายที่มีพฤติกรรมเริ่มต้นและเลือก override ได้ renderNote()
Concrete Class กำหนดรายละเอียดของขั้นตอน PlainTextReport, HtmlReport
Client เรียกใช้รายงานผ่านชนิดของคลาสแม่ TemplateMethodDemo

#Abstract method กับ hook ต่างกันอย่างไร

Abstract method ไม่มี implementation คลาสลูกที่เป็น concrete class จึงต้องมี implementation ของเมธอดนั้นเอง หรือรับ implementation ที่สืบทอดมา [1]

Hook มี implementation เริ่มต้นอยู่แล้ว เช่น คืนสตริงว่าง คลาสลูกจึงเลือกใช้ค่าเริ่มต้นหรือ override เพื่อเพิ่มพฤติกรรมได้

ในตัวอย่างนี้ renderNote() ถูกเรียกทุกครั้งที่สร้างรายงานสำเร็จ แต่รายงานข้อความใช้ค่าเริ่มต้นที่คืนสตริงว่าง จึงไม่มีหมายเหตุปรากฏในผลลัพธ์ คำว่าเลือกใช้ได้จึงหมายถึงเลือกเพิ่มพฤติกรรม ไม่ได้หมายความว่าเมธอดจะไม่ถูกเรียก

#จำเป็นต้องใช้ final หรือไม่

ไม่จำเป็นตามนิยามของแพตเทิร์น แต่การประกาศ template method เป็น final ช่วยป้องกันไม่ให้คลาสลูก override เมธอดหลักและเปลี่ยนลำดับงาน [2]

ตัวอย่างนี้ใช้ public final String generate(...) และกำหนด validate() เป็น private เพื่อเก็บกฎตรวจข้อมูลไว้ในคลาสแม่ ส่วนเมธอดที่เป็นจุดขยายใช้ protected

final ป้องกันการ override ตัวเมธอดหลัก แต่ไม่ได้รับประกันว่าคลาสลูกจะทำงานถูกต้องทั้งหมด คลาสลูกยังอาจคืนค่าผิดรูปแบบหรือโยน exception ได้ จึงต้องกำหนดสัญญาของแต่ละขั้นตอนให้ชัดเจน

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

classDiagram
    direction TB
    class ReportGenerator {
        <<abstract>>
        +generate(items) String
        -validate(items) void
        #renderHeader() String
        #renderBody(items) String
        #renderNote(items) String
        #renderFooter() String
    }
    class PlainTextReport {
        #renderHeader() String
        #renderBody(items) String
        #renderFooter() String
    }
    class HtmlReport {
        #renderHeader() String
        #renderBody(items) String
        #renderNote(items) String
        #renderFooter() String
    }
    class TemplateMethodDemo {
        +main(args) void
    }
    ReportGenerator <|-- PlainTextReport
    ReportGenerator <|-- HtmlReport
    TemplateMethodDemo ..> ReportGenerator : uses

ในแผนภาพ generate() เป็น template method ที่ประกาศ final ส่วน renderHeader(), renderBody() และ renderFooter() เป็น abstract methods รายละเอียด modifier ทั้งหมดแสดงในโค้ดด้านล่าง

#5. โค้ดตัวอย่างที่รันได้

บันทึกโค้ดทั้งหมดเป็นไฟล์ TemplateMethodDemo.java ตัวอย่างรวมหลายคลาสไว้ในไฟล์เดียวเพื่อทดลองได้สะดวก โดยมีเพียง TemplateMethodDemo ที่เป็น public top-level class หากนำไปใช้ในโครงการจริงสามารถแยกแต่ละคลาสเป็นไฟล์ได้

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

public class TemplateMethodDemo {
    public static void main(String[] args) {
        List<String> topics = List.of("Java", "JUnit & Playwright");
        List<ReportGenerator> reports = List.of(
                new PlainTextReport(), new HtmlReport());

        for (ReportGenerator report : reports) {
            System.out.println("=== " + report.getClass().getSimpleName() + " ===");
            System.out.print(report.generate(topics));
        }
    }
}

abstract class ReportGenerator {
    // Template Method: owns the order of operations.
    public final String generate(List<String> items) {
        Objects.requireNonNull(items, "items must not be null");
        List<String> snapshot = List.copyOf(items);
        validate(snapshot);

        return renderHeader()
                + renderBody(snapshot)
                + renderNote(snapshot)
                + renderFooter();
    }

    // Shared step: subclasses cannot replace this validation.
    private void validate(List<String> items) {
        if (items.isEmpty()) {
            throw new IllegalArgumentException("items must not be empty");
        }
        if (items.stream().anyMatch(String::isBlank)) {
            throw new IllegalArgumentException("items must not contain blank text");
        }
    }

    protected abstract String renderHeader();
    protected abstract String renderBody(List<String> items);
    protected abstract String renderFooter();

    // Hook: subclasses may keep this default or override it.
    protected String renderNote(List<String> items) {
        return "";
    }
}

final class PlainTextReport extends ReportGenerator {
    @Override
    protected String renderHeader() {
        return "Report\n";
    }

    @Override
    protected String renderBody(List<String> items) {
        StringBuilder output = new StringBuilder();
        for (String item : items) {
            output.append("- ").append(item).append('\n');
        }
        return output.toString();
    }

    @Override
    protected String renderFooter() {
        return "";
    }
}

final class HtmlReport extends ReportGenerator {
    @Override
    protected String renderHeader() {
        return "<!doctype html>\n<html lang=\"en\">\n"
                + "<head><meta charset=\"UTF-8\"><title>Report</title></head>\n"
                + "<body>\n<h1>Report</h1>\n";
    }

    @Override
    protected String renderBody(List<String> items) {
        StringBuilder output = new StringBuilder("<ul>\n");
        for (String item : items) {
            output.append("<li>").append(escapeHtml(item)).append("</li>\n");
        }
        return output.append("</ul>\n").toString();
    }

    @Override
    protected String renderNote(List<String> items) {
        return "<p>Items: " + items.size() + "</p>\n";
    }

    @Override
    protected String renderFooter() {
        return "</body>\n</html>\n";
    }

    // Escaping for HTML text nodes, not a general HTML sanitizer.
    private static String escapeHtml(String text) {
        return text.replace("&", "&amp;")
                .replace("<", "&lt;")
                .replace(">", "&gt;")
                .replace("\"", "&quot;")
                .replace("'", "&#39;");
    }
}

#เหตุผลของรายละเอียดในโค้ด

  • generate() เป็นเจ้าของลำดับงานทั้งหมด คลาสลูกใช้เมธอดนี้ร่วมกัน
  • List.copyOf() สร้างรายการที่แก้ไขโครงสร้างผ่าน reference นี้ไม่ได้ และไม่ยอมรับสมาชิก null ช่วยไม่ให้ขั้นตอนของคลาสลูกแก้รายการระหว่างการ render
  • validate() ปฏิเสธรายการว่างและข้อความว่างก่อนเรียกขั้นตอน render
  • PlainTextReport ไม่ override renderNote() จึงใช้ hook ที่คืนสตริงว่าง
  • HtmlReport override hook เพื่อแสดงจำนวนรายการก่อนปิดเอกสาร HTML
  • escapeHtml() แปลงอักขระพิเศษก่อนใส่ข้อมูลลงใน HTML text node ฟังก์ชันนี้ไม่ใช่ตัวทำความสะอาด HTML ทั่วไป และไม่ครอบคลุมบริบท URL, JavaScript หรือ CSS

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

#6. วิธีคอมไพล์และรัน

ต้องติดตั้ง JDK เพื่อให้มีทั้ง javac และ java

javac -encoding UTF-8 TemplateMethodDemo.java
java TemplateMethodDemo

หรือใช้ source-file mode บน JDK 17 ขึ้นไป:

java TemplateMethodDemo.java

ผลลัพธ์:

=== PlainTextReport ===
Report
- Java
- JUnit & Playwright
=== HtmlReport ===
<!doctype html>
<html lang="en">
<head><meta charset="UTF-8"><title>Report</title></head>
<body>
<h1>Report</h1>
<ul>
<li>Java</li>
<li>JUnit &amp; Playwright</li>
</ul>
<p>Items: 2</p>
</body>
</html>

โปรแกรมพิมพ์รายงานออกทาง console หากต้องการเปิด HTML ในเบราว์เซอร์ ให้เขียนเฉพาะผลลัพธ์จาก new HtmlReport().generate(topics) ลงไฟล์ .html ด้วย UTF-8

#7. การทำงานทีละขั้นตอน

เมื่อ client เรียก report.generate(topics):

  1. คลาสแม่ตรวจว่ารายการไม่ใช่ null และสร้าง snapshot ของรายการ
  2. คลาสแม่เรียก validate() เพื่อตรวจรายการว่างและข้อความว่าง
  3. เรียก renderHeader() ของชนิดออบเจ็กต์จริง
  4. เรียก renderBody() ของชนิดออบเจ็กต์จริง
  5. เรียก renderNote() ซึ่งอาจใช้ค่าเริ่มต้นหรือ implementation ที่คลาสลูก override
  6. เรียก renderFooter() แล้วคืนรายงานที่ประกอบเสร็จ

แม้ตัวแปร report จะมีชนิด ReportGenerator แต่การเรียกขั้นตอนที่ override ได้จะเลือก implementation ตามออบเจ็กต์จริงผ่าน polymorphism

นี่คือตัวอย่างของแนวคิด Inversion of Control ในระดับคลาส: คลาสลูกกำหนดรายละเอียดไว้ แล้วคลาสแม่เป็นผู้เรียกรายละเอียดเหล่านั้นในเวลาที่กำหนด มักอธิบายด้วยคำว่า Hollywood Principle: “Don't call us, we'll call you.”

หากขั้นตอนใดโยน exception เมธอดจะหยุดและไม่เรียกขั้นตอนถัดไป ดังนั้น renderFooter() ในตัวอย่างนี้เป็นส่วนท้ายของเอกสาร ไม่ใช่กลไก cleanup ทรัพยากรที่รับประกันว่าจะทำงานเสมอ

#8. ทำไมต้องเรียกผ่านชนิด ReportGenerator

ReportGenerator report = new HtmlReport();
String result = report.generate(List.of("Java", "Testing"));

Client รู้จักเพียง API หลัก ส่วนชนิดของรายงานกำหนดรายละเอียดการ render เมื่อเพิ่มรายงานชนิดใหม่จึงใช้โครงของคลาสแม่เดิมได้

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

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

ด้าน ผลต่อการออกแบบ
ใช้โค้ดร่วมกัน รวมกฎตรวจข้อมูลและลำดับการสร้างรายงานไว้ที่เดียว
ความสม่ำเสมอ รายงานแต่ละชนิดใช้ขั้นตอนหลักเหมือนกัน
การขยาย เพิ่มชนิดรายงานผ่านคลาสลูกและจุดขยายที่เตรียมไว้
การทดสอบ ตรวจโครงลำดับร่วมกัน และตรวจผลของแต่ละคลาสลูกแยกได้
การผูกกับคลาสแม่ การเปลี่ยนสัญญาของ protected methods อาจกระทบคลาสลูกหลายตัว
ข้อจำกัด inheritance Java สืบทอดคลาสได้เพียงหนึ่งคลาส จึงใช้โอกาสในการสืบทอดส่วนนี้ไป [1]
จำนวนจุดขยาย Abstract methods และ hooks ที่มากเกินไปทำให้เข้าใจวงจรงานยาก
ความยืดหยุ่น งานที่ต้องสลับพฤติกรรมหลายส่วนอย่างอิสระอาจเหมาะกับ composition มากกว่า

#10. Template Method เทียบกับ Strategy

ประเด็น Template Method Strategy
กลไกหลัก Inheritance และ method overriding Composition และ delegation
ผู้กำหนดโครงงาน คลาสแม่กำหนดโครงและเรียกขั้นตอน Context เรียกออบเจ็กต์ strategy ตามขอบเขตที่ออกแบบ
วิธีเปลี่ยนพฤติกรรม ใช้ออบเจ็กต์ของคลาสลูกอีกชนิด ส่งหรือเปลี่ยนออบเจ็กต์ strategy
การสลับระหว่างทำงาน โดยทั่วไปใช้รายงานอีกออบเจ็กต์หนึ่ง รองรับได้หาก API ของ context เปิดให้เปลี่ยน strategy
เหมาะกับ งานที่มีโครงขั้นตอนร่วมกันและแตกต่างบางขั้นตอน พฤติกรรมที่ต้องประกอบหรือเปลี่ยนได้อย่างอิสระ

ทั้งสองแพตเทิร์นใช้ร่วมกันได้ เช่น คลาสแม่กำหนดลำดับสร้างรายงานด้วย Template Method และมอบหมายขั้นตอนจัดรูปแบบบางส่วนให้ strategy

#11. ตัวอย่างแนวคิดใน Java Collections

AbstractList ของ Java เป็นตัวอย่างที่ช่วยให้เห็นแนวคิดการเตรียมโครง implementation ไว้ให้คลาสลูก เอกสารระบุว่าสามารถสร้าง list ที่ไม่รองรับการแก้ไขผ่าน API โดย implement get(int) และ size() และใช้พฤติกรรมส่วนอื่นที่คลาสแม่เตรียมไว้ได้ [3]

ตัวอย่างเชิงโครงอัลกอริทึมที่ชัดเจนคือ indexOf(Object) ของ AbstractList ซึ่งมีการค้นหาผ่าน list iterator และเรียกพฤติกรรมพื้นฐานของ list อีกทอดหนึ่ง นี่เป็นการเชื่อมโยงเชิงการออกแบบจากพฤติกรรมที่เอกสารระบุ ไม่ใช่การกล่าวว่าเอกสาร API ตั้งชื่อเมธอดนี้ว่า Template Method โดยตรง

ตัวอย่างนี้ยังแสดงว่า template method ไม่จำเป็นต้องเป็น final เสมอไป เอกสาร AbstractList เปิดให้ override เมธอดที่มี implementation แล้วเพื่อให้เหมาะกับประสิทธิภาพของโครงสร้างข้อมูล [3]

#12. ข้อควรระวังในการออกแบบ

  1. เปิดให้ override เท่าที่จำเป็น: ขั้นตอนที่เป็นกฎตายตัวอาจใช้ private ส่วนจุดขยายใช้ protected
  2. อย่าเรียกเมธอดที่ override ได้จาก constructor: implementation ของคลาสลูกอาจทำงานก่อนสถานะของคลาสลูกพร้อม [2]
  3. กำหนดสัญญาของขั้นตอน: ระบุข้อมูลนำเข้า ผลลัพธ์ การเปลี่ยนสถานะ และข้อผิดพลาดที่ยอมรับได้
  4. หลีกเลี่ยง shared mutable state: ตัวอย่างนี้เก็บข้อมูลรายงานในตัวแปรเฉพาะการเรียก ไม่เก็บไว้ใน field ที่เปลี่ยนตลอดเวลา
  5. จัดการ resource ตามวงจรชีวิตจริง: หากเพิ่มการอ่านไฟล์หรือฐานข้อมูล ใช้ try-with-resources หรือ finally ตามความเหมาะสม อย่าคาดหวังว่า hook ท้ายงานจะทำหน้าที่ cleanup อัตโนมัติ
  6. ใช้ inheritance เมื่อสัมพันธ์กันจริง: หากคลาสลูกต้องยกเลิกหรือข้ามขั้นตอนส่วนใหญ่ของคลาสแม่ ควรทบทวนโครงหรือพิจารณา composition

#13. Workshop: เพิ่ม MarkdownReport

เป้าหมาย: เพิ่มรายงาน Markdown โดยใช้ generate() เดิม

#ขั้นตอน

  1. สร้าง final class MarkdownReport extends ReportGenerator
  2. ให้ renderHeader() คืน "# Report\n\n"
  3. ให้ renderBody() แสดงรายการเป็น bullet list
  4. ให้ renderFooter() คืนสตริงว่าง
  5. Override renderNote() ให้แสดงจำนวนรายการ เช่น "\nTotal: 2\n"
  6. เพิ่ม new MarkdownReport() ในรายการ reports ภายใน main()

ใช้ข้อมูลตัวอย่างเดิม ผลลัพธ์เฉพาะส่วน Markdown ควรเป็น:

# Report

- Java
- JUnit & Playwright

Total: 2

สำหรับแบบฝึกหัดนี้ใช้ข้อความธรรมดาตามตัวอย่าง หากนำข้อมูลผู้ใช้ทั่วไปไปสร้าง Markdown ให้กำหนดนโยบายการ escape เครื่องหมายที่มีความหมายใน Markdown เพิ่มเติม

#เกณฑ์ตรวจงาน

  • ไม่แก้เมธอด generate() เพื่อเพิ่มรายงานชนิดใหม่
  • รายงานใหม่ใช้กฎตรวจข้อมูลเดิม
  • หมายเหตุปรากฏหลังเนื้อหาและก่อนส่วนท้าย
  • ทดลองส่งรายการว่างแล้วได้ IllegalArgumentException
  • ทดลองส่งข้อความว่างแล้วไม่เกิดการ render รายงาน

#คำถามอภิปราย

  • เหตุใด renderNote() จึงเหมาะจะเป็น hook?
  • หากเพิ่มเงื่อนไขใน generate() แยกตามชื่อคลาสลูก จะมีผลต่อการขยายระบบอย่างไร?
  • กรณีใดที่ควรเปลี่ยนไปใช้ Strategy หรือ composition?
  • หากงานบางประเภทไม่ต้องตรวจข้อมูลตามกฎเดียวกัน ยังควรอยู่ใน inheritance hierarchy เดียวกันหรือไม่?

#14. การตรวจสอบตัวอย่าง

โค้ดตัวอย่างผ่านการคอมไพล์และรันบน OpenJDK 17 โดยตรวจพฤติกรรมต่อไปนี้:

กรณี ผลที่ตรวจสอบ
สร้างรายงานข้อความและ HTML ผลลัพธ์ตรงกับตัวอย่าง
ตรวจลำดับของขั้นตอน Header → Body → Note → Footer
รายการว่างหรือข้อความว่าง ปฏิเสธก่อนเริ่ม render
รายการหรือสมาชิกเป็น null ได้ NullPointerException
คลาสลูกพยายามเพิ่มสมาชิกใน snapshot ได้ UnsupportedOperationException
ใช้ hook เริ่มต้น ไม่มีข้อความหมายเหตุ
Override hook แสดงหมายเหตุในตำแหน่งที่กำหนด
HTML text ที่มีอักขระพิเศษ แปลงอักขระก่อนแทรกลง text node
Modifier ของ template method generate() เป็น final

#15. แหล่งอ้างอิง

  1. Oracle — Abstract Methods and Classes: กลไก abstract class, abstract method และข้อจำกัดการสืบทอดคลาสใน Java
  2. Oracle — Writing Final Classes and Methods: การป้องกัน method overriding ด้วย final และข้อควรระวังเรื่อง constructor
  3. Oracle — AbstractList — Java SE 21 API: skeletal implementation และข้อกำหนดของเมธอดที่มี implementation ให้แล้ว

เอกสาร Java Tutorials ในข้อ 1–2 เขียนสำหรับ JDK 8 บทความนี้ใช้อ้างอิงกลไกพื้นฐานเรื่อง inheritance, abstract และ final ส่วนโค้ดตัวอย่างใช้ Java 17 เป็นฐานและไม่พึ่งเฟรมเวิร์ก