เมื่อพัฒนาโปรแกรม Java เราอาจต้องนำคลาสจากระบบเดิมหรือไลบรารีภายนอกมาใช้ แต่เมธอดของคลาสนั้นไม่ตรงกับ interface ที่แอปพลิเคชันต้องการ เช่น แอปเรียก send(recipient, message) ขณะที่ระบบ SMS เดิมรับ sendMessage(message, phoneNumber) และคืนรหัสสถานะกลับมา

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

บทความนี้อธิบายแนวคิด องค์ประกอบ ตัวอย่าง Java ที่รันได้ การเปรียบเทียบกับรูปแบบอื่น และแบบฝึกหัดสำหรับใช้สอนหรือฝึกปฏิบัติ

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

Adapter เป็นรูปแบบการออกแบบในกลุ่ม Structural Design Patterns มีหน้าที่แปลง interface ของคลาสหนึ่งให้เป็น interface ที่ผู้เรียกใช้งานคาดหวัง เพื่อให้ส่วนประกอบที่เดิมทำงานร่วมกันไม่ได้สามารถทำงานร่วมกันได้

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

อย่างไรก็ตาม หัวแปลงต้องรักษาความหมายของการทำงานด้วย เช่น หาก API เดิมตอบเพียงว่า “รับข้อความเข้าคิวแล้ว” Adapter ไม่ควรบอกผู้เรียกว่า “ผู้รับอ่านข้อความแล้ว” เพราะเป็นสัญญาคนละระดับ

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

สมมติว่าแอปพลิเคชันกำหนดสัญญาสำหรับส่งการแจ้งเตือนดังนี้

interface NotificationSender {
    void send(String recipient, String message);
}

แต่ไลบรารี SMS เดิมมีเมธอดดังนี้

public int sendMessage(String text, String phoneNumber)

ในตัวอย่างนี้กำหนดให้รหัส 0 หมายถึงระบบยอมรับข้อความ ส่วนรหัสอื่นหมายถึงไม่ยอมรับข้อความ

ความแตกต่างมีสามประเด็น

ประเด็น สัญญาของแอป API เดิม
ชื่อเมธอด send sendMessage
ลำดับอาร์กิวเมนต์ ผู้รับ แล้วข้อความ ข้อความ แล้วผู้รับ
ผลลัพธ์ void; แจ้งความล้มเหลวด้วย exception int; แจ้งผลด้วยรหัสสถานะ

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

Adapter รวมการแปลงเหล่านี้ไว้ในคลาสเดียว โดยฝั่ง Client ใช้สัญญา NotificationSender ต่อไป

#3. องค์ประกอบหลัก

องค์ประกอบ หน้าที่ คลาสในตัวอย่าง
Client เรียกใช้งานผ่านสัญญาที่แอปต้องการ AlertService
Target กำหนด interface ที่ Client ใช้ NotificationSender
Adaptee ระบบหรือคลาสเดิมที่ interface ไม่ตรงกัน LegacySmsGateway
Adapter ทำตาม Target และแปลงคำสั่งไปยัง Adaptee SmsAdapter

แผนภาพต่อไปนี้แสดงความสัมพันธ์ของคลาส โดย SmsAdapter เก็บอ้างอิงไปยัง LegacySmsGateway และส่งต่องานให้คลาสนั้น

classDiagram
    direction TB
    class NotificationSender {
        <<interface>>
        +send(String recipient, String message) void
    }
    class AlertService {
        -NotificationSender sender
        +notifyUser(String recipient, String message) void
    }
    class SmsAdapter {
        -LegacySmsGateway gateway
        +send(String recipient, String message) void
    }
    class LegacySmsGateway {
        +sendMessage(String text, String phoneNumber) int
    }
    AlertService --> NotificationSender : uses
    SmsAdapter ..|> NotificationSender : implements
    SmsAdapter --> LegacySmsGateway : delegates

Client รู้จักเฉพาะ Target ส่วนโค้ดประกอบระบบ เช่น main() เป็นผู้สร้าง Adapter และส่ง dependency เข้าไป

#4. Workshop: สร้างระบบแจ้งเตือนด้วย Object Adapter

เวลาโดยประมาณ: 20–30 นาที
พื้นฐาน: class, interface, constructor และ exception
เครื่องมือ: JDK 17 ขึ้นไป ไม่ต้องใช้ Maven หรือไลบรารีเพิ่มเติม

#4.1 สร้างไฟล์ AdapterDemo.java

บันทึกโค้ดต่อไปนี้ในไฟล์ชื่อ AdapterDemo.java โค้ดทั้งหมดอยู่ในไฟล์เดียว โดยมี AdapterDemo เป็นคลาส public เพียงคลาสเดียว

LegacySmsGateway เป็นตัวจำลองไลบรารีเดิม แสดงข้อความทางคอนโซลและคืนสถานะ 0 ไม่มีการส่ง SMS จริง

import java.util.Objects;

// Target: the contract expected by the application.
interface NotificationSender {
    void send(String recipient, String message);
}

// Adaptee: a stand-in for a legacy library that cannot be changed.
class LegacySmsGateway {
    // Legacy convention: message first, recipient second; 0 means accepted.
    public int sendMessage(String text, String phoneNumber) {
        System.out.printf("[SMS] to=%s, message=%s%n", phoneNumber, text);
        return 0;
    }
}

// Object Adapter: wraps the legacy object and translates its contract.
final class SmsAdapter implements NotificationSender {
    private final LegacySmsGateway gateway;

    public SmsAdapter(LegacySmsGateway gateway) {
        this.gateway = Objects.requireNonNull(gateway, "gateway");
    }

    @Override
    public void send(String recipient, String message) {
        int status = gateway.sendMessage(message, recipient);
        if (status != 0) {
            throw new IllegalStateException("SMS gateway rejected message: " + status);
        }
    }
}

// A native implementation that already follows the Target contract.
final class ConsoleSender implements NotificationSender {
    @Override
    public void send(String recipient, String message) {
        System.out.printf("[CONSOLE] to=%s, message=%s%n", recipient, message);
    }
}

// Client: depends only on the application's contract.
final class AlertService {
    private final NotificationSender sender;

    public AlertService(NotificationSender sender) {
        this.sender = Objects.requireNonNull(sender, "sender");
    }

    public void notifyUser(String recipient, String message) {
        sender.send(recipient, message);
    }
}

public class AdapterDemo {
    public static void main(String[] args) {
        NotificationSender sms = new SmsAdapter(new LegacySmsGateway());
        AlertService smsAlerts = new AlertService(sms);
        smsAlerts.notifyUser("0812345678", "Your course registration is confirmed.");

        AlertService consoleAlerts = new AlertService(new ConsoleSender());
        consoleAlerts.notifyUser("student-001", "Your course registration is confirmed.");
    }
}

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

เปิด Terminal หรือ Command Prompt ในโฟลเดอร์ที่บันทึกไฟล์ แล้วรัน

javac -encoding UTF-8 AdapterDemo.java
java AdapterDemo

ผลลัพธ์ที่คาดหวัง

[SMS] to=0812345678, message=Your course registration is confirmed.
[CONSOLE] to=student-001, message=Your course registration is confirmed.

หากระบบแจ้งว่าไม่พบ javac ให้ตรวจสอบว่าติดตั้ง JDK และเพิ่มโฟลเดอร์ bin ของ JDK ใน PATH แล้ว

#4.3 Adapter แปลงอะไรบ้าง

ส่วนสำคัญอยู่ในเมธอดนี้

@Override
public void send(String recipient, String message) {
    int status = gateway.sendMessage(message, recipient);
    if (status != 0) {
        throw new IllegalStateException("SMS gateway rejected message: " + status);
    }
}

การทำงานมีดังนี้

  1. รับคำสั่งผ่าน send(recipient, message) ตาม Target
  2. เปลี่ยนไปเรียก sendMessage(message, recipient) ตาม API เดิม
  3. สลับอาร์กิวเมนต์ให้ข้อความอยู่ก่อนหมายเลขโทรศัพท์
  4. แปลรหัสสถานะที่ไม่ใช่ 0 เป็น exception เพื่อไม่ซ่อนความล้มเหลว

การคืนค่าปกติจาก send() ในตัวอย่างหมายถึง gateway ยอมรับคำขอ ไม่ได้รับประกันการส่งถึงเครื่องผู้รับ

ส่วน Objects.requireNonNull() ใน constructor ช่วยตรวจสอบ dependency ที่จำเป็นตั้งแต่สร้างออบเจ็กต์ การตรวจสอบรูปแบบหมายเลขโทรศัพท์หรือความยาวข้อความสามารถเพิ่มตามสัญญาที่ระบบกำหนด

#4.4 เหตุใดจึงเปลี่ยน implementation ได้

AlertService รับ NotificationSender ผ่าน constructor และไม่อ้างถึง LegacySmsGateway จึงใช้ได้ทั้ง

new AlertService(new SmsAdapter(new LegacySmsGateway()));
new AlertService(new ConsoleSender());

ConsoleSender ทำตาม Target ได้อยู่แล้ว จึงไม่ต้องมี Adapter ส่วน SmsAdapter จำเป็นเพราะคลาสเดิมมีสัญญาไม่ตรงกัน

การสลับ implementation ผ่าน interface เป็นประโยชน์จาก polymorphism และ dependency injection ส่วนหน้าที่เฉพาะของ Adapter คือการเชื่อมสัญญาที่ไม่เข้ากัน การมี interface เพียงอย่างเดียวยังไม่ได้หมายความว่าใช้ Adapter Pattern

#5. Object Adapter กับ Class Adapter

#Object Adapter: ห่อออบเจ็กต์ด้วย composition

ตัวอย่างหลักใช้แนวทางนี้

final class SmsAdapter implements NotificationSender {
    private final LegacySmsGateway gateway;
    // constructor และเมธอดแปลงคำสั่ง
}

Adapter รับออบเจ็กต์ระบบเดิมเข้ามาแล้วส่งต่องานให้ สามารถห่อคลาส final ได้ และสามารถรับออบเจ็กต์ subclass หรือ test double ที่เข้ากันได้ตามชนิดที่ประกาศ

#Class Adapter: สืบทอดจากคลาสเดิม

ใน Java สามารถใช้รูปแบบนี้เมื่อ Target เป็น interface และ Adaptee เป็นคลาสที่อนุญาตให้สืบทอด เช่น คลาส LegacySmsGateway ในตัวอย่าง

class SmsClassAdapter extends LegacySmsGateway
        implements NotificationSender {
    @Override
    public void send(String recipient, String message) {
        int status = sendMessage(message, recipient);
        if (status != 0) {
            throw new IllegalStateException("SMS gateway rejected message: " + status);
        }
    }
}

Java อนุญาตให้คลาสมี superclass โดยตรงได้หนึ่งคลาส และทำตาม interface ได้หลาย interface ดังนั้นรูปแบบที่ต้องสืบทอดจากทั้ง Target class และ Adaptee class พร้อมกันจึงใช้ตรง ๆ ไม่ได้ [2]

ประเด็น Object Adapter Class Adapter
กลไก implements Target และเก็บ Adaptee extends Adaptee และ implements Target
รับออบเจ็กต์เดิมที่มีอยู่แล้ว รับผ่าน constructor ได้ ผูกกับการสืบทอดและการสร้างออบเจ็กต์ของ Adapter
Adaptee เป็นคลาส final ใช้ได้ ใช้ไม่ได้
การเปลี่ยน dependency เพื่อทดสอบ ส่ง test double ที่เข้ากันได้เข้าไปได้ ผูกกับ superclass มากกว่า
ข้อจำกัดหลัก ต้องเขียนโค้ดส่งต่องาน ข้อจำกัดของ inheritance และการเข้าถึงสมาชิก

สำหรับตัวอย่างนี้ Object Adapter เหมาะสมเพราะแยกการประกอบระบบออกจากการแปลงคำสั่งได้ชัดเจน

#6. ตัวอย่างแนวคิด Adapter ใน Java API

InputStreamReader รับ InputStream ซึ่งอ่านข้อมูลเป็นไบต์ แล้วเปิดให้ใช้งานผ่าน Reader ซึ่งอ่านเป็นตัวอักษร โดยถอดรหัสตาม charset ที่ระบุ เอกสาร Java API อธิบายพฤติกรรมการเชื่อม byte stream กับ character stream นี้ไว้โดยตรง [1]

ความสัมพันธ์นี้อธิบายในมุมของ Adapter ได้ดังนี้

บทบาท Java API
Target Reader ซึ่งเป็น abstract class
Adapter InputStreamReader
Adaptee InputStream ที่ส่งเข้า constructor

ตัวอย่างต่อไปนี้บันทึกในไฟล์ ReaderAdapterDemo.java

import java.io.ByteArrayInputStream;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.Reader;
import java.nio.charset.StandardCharsets;

public class ReaderAdapterDemo {
    public static void main(String[] args) throws Exception {
        byte[] data = "Hello Adapter".getBytes(StandardCharsets.UTF_8);
        try (InputStream input = new ByteArrayInputStream(data);
             Reader reader = new InputStreamReader(input, StandardCharsets.UTF_8)) {
            int character;
            while ((character = reader.read()) != -1) {
                System.out.print((char) character);
            }
            System.out.println();
        }
    }
}

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

javac -encoding UTF-8 ReaderAdapterDemo.java
java ReaderAdapterDemo

ผลลัพธ์

Hello Adapter

จุดสำคัญคือฝั่งผู้ใช้เรียกเมธอดของ Reader ขณะที่ InputStreamReader อ่านและถอดรหัสไบต์จาก InputStream ที่อยู่ภายใน ตัวอย่างนี้ยังแสดงว่า Target ไม่จำเป็นต้องเป็น Java interface เสมอไป แต่เป็น abstract class ได้

คำว่า “bridge” ในคำอธิบายภาษาอังกฤษของ API หมายถึงการเชื่อม stream สองรูปแบบ ไม่ใช่การจัดประเภทว่าคลาสนี้เป็น Bridge Pattern ตาม GoF

#7. ใช้เมื่อใด และได้ประโยชน์อะไร

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

  • เชื่อม legacy library ที่แก้ไขไม่ได้เข้ากับ interface ใหม่
  • ครอบ SDK ของผู้ให้บริการภายนอกด้วยสัญญาของแอป
  • แปลง DTO ชื่อฟิลด์ หรือหน่วยข้อมูลระหว่างสองระบบ
  • ย้ายระบบทีละส่วน โดยให้ Client ใช้สัญญาเดิมของแอปต่อไป

ผลที่ได้คือรายละเอียดของระบบภายนอกอยู่ในจุดที่ชัดเจน Client อ้างอิง Target และสามารถเพิ่ม Adapter สำหรับผู้ให้บริการรายใหม่ได้ หากผู้ให้บริการนั้นทำตามความหมายของ Target ได้จริง

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

Adapter ไม่สามารถสร้างความสามารถที่ระบบเดิมไม่มี เช่น เปลี่ยน API ที่ไม่รองรับธุรกรรมให้รับประกันธุรกรรมได้ด้วยการเปลี่ยนชื่อเมธอดเพียงอย่างเดียว

#8. เปรียบเทียบกับรูปแบบที่มักสับสน

Pattern เป้าหมายหลัก คำถามช่วยแยกแยะ
Adapter แปลง interface ที่ไม่เข้ากันให้ Client ใช้ได้ มีสัญญาเดิมที่ต้องแปลงเข้าหาสัญญาที่ Client คาดหวังหรือไม่?
Facade สร้างทางเข้าที่ใช้ง่ายสำหรับระบบย่อยที่ซับซ้อน กำลังรวมหลายขั้นตอนให้เรียกผ่านจุดเดียวหรือไม่?
Decorator เพิ่มพฤติกรรมโดยยังใช้งานผ่านสัญญาเดิมได้ กำลังเพิ่ม logging, metrics หรือพฤติกรรมรอบงานเดิมหรือไม่?
Bridge แยก abstraction กับ implementation ให้พัฒนาแยกกัน กำลังออกแบบให้สองมิติเปลี่ยนแปลงแยกกันตั้งแต่โครงสร้างหรือไม่?

ตัวอย่างเช่น SmsAdapter แปลง API เดิมให้เป็น NotificationSender ส่วนคลาสที่ห่อ NotificationSender เพื่อจับเวลาและบันทึก log โดยยังมีเมธอด send() แบบเดิม อาจออกแบบเป็น Decorator

โครงสร้างบางส่วนอาจคล้ายกันเพราะใช้ composition เหมือนกัน จึงควรพิจารณาจากปัญหาและเจตนาของการออกแบบด้วย

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

#เรียก Adaptee โดยตรงจาก Client

หาก AlertService ยังเรียก gateway.sendMessage(...) เอง รายละเอียดของ API เดิมก็ยังรั่วเข้ามาใน Client ควรให้ Client เรียก Target และให้ Adapter รับผิดชอบการแปลง

#สลับอาร์กิวเมนต์ผิด

ในตัวอย่าง อาร์กิวเมนต์ทั้งสองเป็น String การส่งผิดลำดับจึงยังคอมไพล์ผ่าน ควรตรวจสอบค่าที่ส่งจริง ไม่ตรวจเพียงว่าไม่มี exception

#ซ่อนความล้มเหลวของระบบเดิม

การละเลยรหัสที่ไม่ใช่ 0 อาจทำให้ Client เข้าใจว่างานสำเร็จ Adapter ต้องแปลงสถานะให้สอดคล้องกับสัญญาของ Target

#ใส่กฎธุรกิจจำนวนมากใน Adapter

การอนุมัติการแจ้งเตือน การเลือกผู้รับ หรือกฎสิทธิ์การใช้งานควรอยู่ในส่วนที่รับผิดชอบกฎเหล่านั้น ส่วน Adapter มุ่งที่การเชื่อม API และรักษาความหมายของสัญญา

#ออกแบบ Target ตาม SDK รายใดรายหนึ่งมากเกินไป

หาก Target เปิดเผย DTO หรือชนิด exception เฉพาะของผู้ให้บริการ Client จะยังผูกกับผู้ให้บริการนั้น ควรกำหนดสัญญาตามความต้องการของแอป และแปลงชนิดภายนอกที่ขอบเขตระบบ

#10. กิจกรรมทดลองและแบบฝึกหัด

#กิจกรรมที่ 1: สังเกตความผิดพลาดที่ compiler ตรวจไม่พบ

เปลี่ยนบรรทัดใน SmsAdapter จาก

gateway.sendMessage(message, recipient);

เป็น

gateway.sendMessage(recipient, message);

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

#กิจกรรมที่ 2: ทดลองเส้นทางล้มเหลว

เพื่อจำลองข้อผิดพลาดในการฝึกปฏิบัติ เปลี่ยน return 0; ใน LegacySmsGateway เป็น return 9; ชั่วคราว แล้วรัน AdapterDemo อีกครั้ง

ผลที่ควรสังเกตคือ SmsAdapter โยน IllegalStateException ที่มีรหัส 9 และเนื่องจาก main() ไม่ได้ดัก exception โปรแกรมจะหยุดก่อนทำตัวอย่าง Console การทดลองนี้เป็นการปรับตัวจำลองเพื่อทดสอบ ไม่ใช่ข้อกำหนดว่าการใช้ Adapter ต้องแก้ไลบรารีเดิม

#กิจกรรมที่ 3: เพิ่ม Adapter ใหม่

สมมติ API อีเมลภายนอกมีเมธอด

boolean deliverEmail(String body, String emailAddress)

ให้สร้าง EmailAdapter ที่ทำตาม NotificationSender โดยแปลง false เป็น exception แล้วใช้กับ AlertService โดยไม่แก้คลาส AlertService

#ประเด็นสำหรับตรวจสอบผลงาน

  • Client อ้างอิง Target และไม่เรียก API เดิมโดยตรง
  • Adapter ส่งค่าผู้รับและข้อความในลำดับที่ถูกต้อง
  • มีการแปลงผลลัพธ์ทั้งกรณียอมรับคำขอและปฏิเสธคำขอ
  • เปลี่ยน implementation ได้โดยไม่แก้โค้ดภายใน Client
  • อธิบายได้ว่างานใดเป็นการแปลงสัญญา และงานใดเป็นกฎธุรกิจ

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

  1. Oracle. InputStreamReader — Java SE 17 API. ใช้อ้างอิงพฤติกรรมการแปลง byte stream เป็น character stream และการกำหนด charset
  2. Oracle. Java Language Specification, Chapter 8: Classes, โดยเฉพาะหัวข้อ 8.1.4 และ 8.1.5 ใช้อ้างอิง superclass และ superinterfaces
  3. Erich Gamma, Richard Helm, Ralph Johnson, John Vlissides. Design Patterns: Elements of Reusable Object-Oriented Software. Addison-Wesley, 1994. บท Adapter ใช้เป็นเอกสารอ่านเพิ่มเติมสำหรับรูปแบบการออกแบบ

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