meaw meaw...
Pluggable Component

homearticlesbooksshowcasequiz
By PradubKeng
  เกริ่นนำ
วันนี้เรามาคุยกันถึงเรื่อง ๆ หนึ่งที่ถูกตั้งเป็นคำถาม ๆ ไว้ในส่วนของ quiz ซึ่งรอมานานกับคำตอบ วันนี้ได้มีคนมาให้คำตอบกับเรา โดยก่อนที่เราจะพูดคำเฉลย เรามาย้อนดูก่อนว่าคำถามนั้นได้ถามว่าอะไรบ้าง

คำถาม: สมมุติว่าเรามีคลาสอยู่ 3 คลาส คือคลาส A, B และ C โดยคลาส B และ C ทำการ implement ตัว Interface ตัวเดียวกันชื่อ Interface D เราจะทำอย่างไรให้ตอนแรก คลาส A คุยกับคลาส B ได้ผลแบบหนึ่ง ต่อมาให้คลาส A คุยกับคลาส C ได้ผลอีกแบบหนึ่ง โดยที่เราไม่จำเป็นต้องทำการคอมไพล์โปรแกรมอีก ในกรณีที่เราเปลี่ยนการเชื่อมต่อระหว่างคลาส A กับคลาส B ไปเป็นคลาส A กับคลาส C

Pluggable Component Concept
ข้อความข้างล่างนี้เป็นคำเฉลย ซึ่งถูกคัดลอกมาจากส่วนหนึ่งของ email จากคุณ วโรดม วีระพันธ์ โดยได้รับอนุญาตจากทางเจ้าตัวแล้ว : )
---
คำว่าคุยในที่นี้หมายถึงเรียกใช้ method ของ อีก class หรืออะไรครับ?
ถ้าเป็นการเรียก method ของ อีก class หนึ่ง
เราก็แค่สร้าง object B หรือ C จากนั้นก็เรียก b.method(); c.method();
โดยที่ method เป็น method ที่ Implement มาจาก Interface D ครับ
---
นี่คือคำตอบที่ถูกต้องในลักษณะเชิงบรรยาย อย่างไรก็ตามเพื่อให้เราเข้าใจเกี่ยวกับคำตอบมากขึ้น เรามาลองดูว่าถ้าเขียนโค๊ดแบบเต็มรูปแบบ หน้าตาของโค๊ดจะออกมาเป็นอย่างไร

จากคำถามจะเห็นว่ามี Interface อันหนึ่งถูกกำหนดไว้ชื่อ D ดังนั้นขั้นแรกเราต้องทำการ define ตัว Interface นี้ขึ้นมาเสียก่อน

interface D {
  public void init();
  public void run();
}

เรากำหนดให้ Interface D มี method อยู่ 2 ตัวคือ init() และ run() โดยคลาสใดก็ตามที่ implement ตัว Interface นี้จะต้องมีส่วนที่เป็น Implementation ของ method ทั้งสองนี้อยู่ ซึ่งจากคำถามคลาส B และคลาส C จะต้องทำการ implement ตัว Interface D ดังนั้นเราก็จัดการสร้างคลาส B และคลาส C ขึ้นมา โดยให้คลาสทั้งสองทำการ implement ตัว Interface D ดังปรากฎอยู่ในโค๊ดข้างล่าง

class B implements D {
  public void init() {
    System.out.println("Class B: init()");  
  }  
  
  public void run() {
    System.out.println("Class B: run()");
  }  
}

class C implements D {
  public void init() {
    System.out.println("Class C: init()");  
  }  
  
  public void run() {
    System.out.println("Class C: run()");
  }
}
คำถามยังบอกเราอีกว่า เราต้องสร้างคลาสขึ้นมาอีกคลาสหนึ่งชื่อคลาส A โดยคลาสนี้เมื่อคุยกับคลาส B จะได้ผลแบบหนึ่ง เมื่อเปลี่ยนมาคุยกับคลาส C จะได้ผลอีกแบบหนึ่ง คำว่าคุยในที่นี้หมายถึงการเรียก method ที่อยู่ในคลาส B หรือคลาส C ซึ่งแม้แต่คุณวโรดมก็ยังสงสัยเกี่ยวกับคำ ๆ นี้ (แต่ก็ยังตอบคำถามถูกในท้ายที่สุด) นอกจากนี้ในคำถามยังมีคอนดิชั่นอีกอย่างหนึ่งคือ ตอนเราเปลี่ยนการคุยจากคลาส A กับคลาส B ไปเป็นคลาส A กับคลาส C ห้ามมีการคอมไพล์เกิดขึ้น ดังนั้นอย่างแรกที่เราต้องทำคือ การให้คลาส A คุยกับคลาส B หรือคลาส C ผ่านทาง type ที่เป็น Interface ซึ่งก็คือ Interface D ซึ่งเป็น Interface ที่ทั้งสองคลาสได้ทำการ implement ไว้แล้ว วิธีการทำเช่นนี้เป็นการซ่อนส่วนที่เป็น Implementation ที่แท้จริงของคลาส โดยให้ตัว client (ซึ่งก็คือ คลาส A) รู้จักแต่ส่วนที่เป็น Interface ของคลาสแทน ดังตัวอย่างของโค๊ดข้างล่าง
class A {
  public void callTagetClass() {
    D d = loadTargetClass();
    
    if (d != null) {
      d.init();
      d.run();    
    }
  }
...
}
จากโค๊ด เราจะเห็นว่าไม่มีร่องรอยของคลาส B หรือคลาส C ปรากฎอยู่ในคลาส A เลย เราออกแบบให้คลาส A คุยกับคลาส B หรือคลาส C ผ่านทางรูปแบบของ Interface D แทน โดยคลาส B หรือคลาส C นี้จะถูกโหลดและแปลง type ให้กลายเป็น Interface D ผ่านทาง loadTargetClass() ซึ่งเป็น method ที่อยู่ในคลาส A ดังที่ปรากฎอยู่ในโค๊ดข้างล่าง
class A {
...
  private D loadTargetClass() {
    Properties props = new Properties();
    InputStream stream = A.class.getResourceAsStream("target.properties");
    if (stream != null) {
      try {
        props.load(stream); 
      } catch (IOException io) {
          io.printStackTrace();
      }
    } else {
      System.out.println("target.properties is missing");
      return null;
    }
    
    ClassLoader cl = Thread.currentThread().getContextClassLoader();
    Class targetClass = null;
    
    try {
      targetClass = cl.loadClass(props.getProperty("targetClass"));
      if (targetClass != null) {
        return (D) targetClass.newInstance();
      }
    } catch (Exception e) {
        e.printStackTrace();
    }
    
    return null;
  }  
}
การเปลี่ยนการคุยกันระหว่างคลาส A กับคลาส B ไปเป็นคลาส A กับคลาส C ในช่วง Runtime มีเคล็ด(ไม่)ลับคือ การให้คลาส A เลือกที่จะโหลดคลาส B หรือคลาส C ผ่านทาง properties* ไฟล์ โดยเราสามารถเปลี่ยนชื่อคลาสจาก B ไป C หรือ C ไป B ได้โดยไม่ต้องมีการคอมไพล์คลาส A อีก ในกรณีของเรา ๆ ใส่ชื่อของคลาสที่จะโหลดเข้าไปในไฟล์ชื่อ target.properties ซึ่งมีอยู่เพียง 3 บรรทัดคือ
# target.properties
# target class loaded by class A at runtime
targetClass=B
* สำหรับรายละเอียดเพิ่มเติมเกี่ยวกับ properties ผู้อ่านสามารถศึกษาเพิ่มเติมได้จาก Using properties file

ถ้าเราต้องการเปลี่ยนคลาสที่ถูกโหลดจากคลาส B ไปเป็นคลาส C เราก็เพียงเปลี่ยนค่าของ targetClass เป็น

# target.properties
# target class loaded by class A at runtime
targetClass=C
ก็เป็นอันเสร็จพิธี

มาถึงตรงนี้หลายคนอาจสงสัยว่า พอใส่ชื่อคลาสเข้าไปใน properties ไฟล์แล้ว คลาส A จะโหลดคลาสพวกนี้ขึ้นมาได้อย่างไร? ขั้นแรกเราใช้ A.class เพื่ออ้างถึง instance ของคลาส Class (java.lang.Class) ที่ใช้สำหรับสร้าง instance ของคลาส A ขึ้นมา เหตุผลคือเราต้องการเรียกใช้ method ที่ชื่อ getResourceAsStream(String name) ที่อยู่ในคลาส Class นั้น เพื่อจะได้ InputStream ที่ใช้สำหรับโหลดไฟล์ target.properties โดยผ่านทางคลาส Properties ดัง code snippet ข้างล่าง

...
Properties props = new Properties();
    InputStream stream = A.class.getResourceAsStream("target.properties");
    if (stream != null) {
      try {
        props.load(stream); 
...
หลังจากที่เราได้ชื่อคลาสที่เราจะโหลดบรรจุอยู่ใน props แล้ว (ชื่อคลาส = props.getProperty("targetClass")) ทีนี้เราก็เพียงใช้ ClassLoader โหลด instance ของคลาส Class ที่เราจะใช้สำหรับสร้าง instance ของคลาสที่เป็นคลาสเป้าหมายขึ้นมา
...
ClassLoader cl = Thread.currentThread().getContextClassLoader();
Class targetClass = null;

try {
targetClass = cl.loadClass(props.getProperty("targetClass")); ...
ท้ายสุดเราเพียงสร้าง instance ของคลาสเป้าหมาย โดยการใช้ Class.newInstance() แล้วแปลง type ของคลาสนั้นให้กลายเป็น Interface D
...
if (targetClass != null) {
  return (D) targetClass.newInstance();
}
...
Note: คลาสทุกคลาสจะถูกโหลดขึ้นโดยสิ่งหนึ่งที่เรียกว่า ClassLoader โดยขั้นแรกตัว ClassLoader นี้จะสร้าง instance ของคลาส Class ขึ้นมาเพื่อใช้เป็นตัวแทนของคลาสที่ถูกโหลด โดยเมื่อไรก็ตามที่เราต้องการ instance ของคลาสที่ถูกโหลดนี้ instance ของคลาส Class ดังกล่าวจะมีหน้าที่ในการสร้าง instance ของคลาสนั้นให้กับเรา
To get an instance of  "com.jarticles.MyClass" class:
   ClassLoader (load bytecode of com.jarticles.MyClass and create an instance of Class class to represent it)
        |
      Class       (an instance of Class class that represents com.jarticles.MyClass class)
   /    |    \   
inst1 inst2 inst3 ... instN   (inst = instance of com.jarticles.MyClass class)
ผู้อ่านสามารถดาวโหลดไฟล์ที่เกี่ยวข้องทั้งหมดได้จาก Quiz1.java

ให้ลองรันผลโดยพิมพ์ java Quiz1 ผลที่ออกมาก็จะเป็นดังนี้

>java Quiz1
>Class B: init()
>Class B: run()
คราวนี้ให้ลองเปลี่ยนค่าของ targetClas=B ที่อยู่ในไฟล์ target.properties ให้กลายเป็น targetClass=C ผลที่ออกมาก็จะเป็น
>java Quiz1
>Class C: init()
>Class C: run()

Real World Example
หลังจากที่เราได้เรียนรู้เกี่ยวกับเบสิคคอนเซ็ปต์ของ Pluggable Component มาแล้ว คราวนี้เรามาลองดูการประยุกต์ใช้งานคอนเซ็ปต์นี้ในชีวิตจริงกันบ้าง สมมุติว่าเราต้องการสร้างโปรแกรมเขียนกราฟขึ้นมาโปรแกรมหนึ่ง โดยมีตัวคอนโทรลเลอร์เป็นกลุ่มของ JSlider อย่างรูปที่ 1

Graph without chart    Graph with a normal chart    Graph with normal and pie charts
(1) (2) (3)

ถ้าเราอยากให้โปรแกรมของเรามีความสามารถในการเพิ่มกราฟชนิดต่าง ๆ เข้ามาในตัวเองได้ในช่วง Runtime อย่างรูปที่ 2 และ 3 เราควรจะออกแบบโปรแกรมกราฟของเราอย่างไรบ้าง

ขั้นตอนต่าง ๆ ที่เราจะพูดถึงต่อไปนี้ เป็นเพียงวิธีการหนึ่งในหลายวิธีที่เราสามารถทำได้ โดยอาศัยคอนเซ็ปต์ของ Pluggable Component

1. สร้างตัวโมเดลที่ใช้เป็นตัวเก็บเดต้าขึ้นมาก่อน
ขั้นแรกเราสร้างคลาสขึ้นมาคลาสหนึ่งชื่อ SampleModel (SampleModel.java) โดยจุดประสงค์ของคลาสนี้คือ การใช้เป็นตัวเก็บข้อมูลที่ใช้สำหรับวาดกราฟ ซึ่งจะมี public API ไว้ให้คลาสอื่นใช้ในการเปลี่ยนแปลงและแก้ไขข้อมูลดังกล่าว

public class SampleModel {
  private int samples[] = null;
  
  public SampleModel(int[] samples) {
    this.samples = samples;  
  }    
  
  public int getSize() {
    return samples.length;  
  }
  
  public int[] getSamples() {
    return samples;  
  }
  
  public void setValue(int index, int value) {
    samples[index] = value;  
  }
}
2. สร้างตัวคอนโทรลเลอร์ขึ้นมาสำหรับเปลี่ยนแปลงข้อมูลที่อยู่ในโมเดล
เราสร้างคลาสขึ้นมาอีกคลาสหนึ่งชื่อคลาส SliderController (SliderController.java) คลาสนี้เราใช้สำหรับติดต่อกับคลาส SampleModel เพื่อเปลี่ยนแปลงและแก้ไขข้อมูลต่าง ๆ ตามที่เราต้องการ

Note: หลายคนพอได้ยินคำว่า Model และ Controller อาจจะนึกถึงเกี่ยวกับคอนเซ็ปต์ของ MVC แต่เนื่องด้วยบทความนี้เป็นบทความที่เน้นเกี่ยวกับ Pluggable Component ดังนั้นเราจะไม่เขียนโค๊ดในลักษณะที่เป็น MVC เพื่อให้ตัวอย่างของเรามีความซับซ้อนน้อยลง

public class SliderController extends JPanel {
  private SampleModel sampleModel = null;
  private PluginTabbedPane pluginTabbedPane = null;
  ...
  class SliderListener implements ChangeListener {
    public void stateChanged(ChangeEvent e) {
      JSlider source = (JSlider) e.getSource();
      sampleModel.setValue(Integer.parseInt(source.getName()), (int) source.getValue());
      
      int[] samples = sampleModel.getSamples();
      pluginTabbedPane.updateChart(samples);
    }
  }
}
จากโค๊ดข้างบน เราจะเห็นว่าเมื่อไรก็ตามที่ JSlider มีการเปลี่ยนแปลงค่า ตัว sampleModel ก็จะถูกเซ็ตค่าใหม่ลงไปด้วยทุกครั้ง (sampleModel.setValue(...)) นอกจากนี้ตัว Chart ซึ่งเราจะพูดถึงต่อไปก็จะถูกอัฟเดทตามไปด้วยเช่นกัน ตอนนี้เมื่อเรานำคลาส SampleModel และคลาส SliderController มาประกอบกัน เราก็จะได้โปรแกรมที่เป็นอย่างรูปที่ 1 ข้างบน

3. สร้างส่วนที่ใช้เป็นที่เก็บตัวกราฟต่าง ๆ
ในการโหลดตัวกราฟต่าง ๆ เข้ามาในโปรแกรม เราจะต้องมีที่ ๆ หนึ่งซึ่งใช้สำหรับเก็บตัวกราฟเหล่านี้ (ต่อไปเราจะเรียกตัวกราฟเหล่านี้ว่า Chart) เราสร้างคลาสขึ้นมาอีกคลาสหนึ่งชื่อคลาส PluginTabbedPane (PluginTabbedPane.java) ดัง code snippet ข้างล่าง

public class PluginTabbedPane extends JPanel implements ChangeListener {
  private JTabbedPane tabbedPane = null;
  private Chart selectedChart = null;
  ...
  public void addChart(Chart chart) {
    if (firstChart) {
      selectedChart = chart;
      firstChart = false;  
    }
    tabbedPane.addTab(chart.getName(), chart.getChart());
  }
  
  public void updateChart(int[] samples) {
    if (selectedChart != null) {
      selectedChart.update(samples);  
    }
  }  
  
  // Implementation of ChangeListener interface
  public void stateChanged(ChangeEvent e) {
    JTabbedPane tabbedPane = (JTabbedPane) e.getSource();
    Component component = tabbedPane.getSelectedComponent();
    selectedChart = (Chart) component;  // casting to Chart? not good here
  }
}
คลาสนี้บรรจุตัว tabbedPane ซึ่งเป็น instance ของคลาส JTabbedPane ไว้ข้างใน โดยเมื่อไรก็ตามที่มี Chart เข้ามา คลาสนี้ก็จะใส่ Chart เข้าไปใน tabbedPane นี้อีกทีหนึ่ง จุดสำคัญจุดหนึ่งในคลาสนี้คือ การ register ตัวเองไว้กับ tabbedPane โดยทำการ implement ตัว Interface ที่ชื่อ ChangeListener ซึ่งเมื่อใดก็ตามที่เราทำการเปลี่ยนแทป คลาสนี้ก็จะทำการเปลี่ยนตัว Chart ให้ตรงกับแทปที่เราเลือก

4. สร้างส่วนที่เป็น Contract ซึ่งเป็นหัวใจของ Pluggable Component
การที่จะให้คลาส PluginTabbedPane สามารถเก็บและควบคุม Chart ได้หลาย ๆ แบบ เราจะต้องให้คลาส PluginTabbedPane นี้คุยกับ Interface แล้วให้คลาสที่เป็นตัว Chart ต่าง ๆ ทำการ implement ตัว Interface นี้ ดังนั้นตัว Chart ที่ปรากฎอยู่ในคลาส PluginTabbedPane จะต้องเป็น Interface ดังโค๊ดข้างล่าง

 public interface Chart {
  public JComponent getChart();
  public String getName();
  public void init(int[] samples);
  public void update(int[] samples);  
}
 
ตัว Interface Chart นี้มี method อยู่เพียง 4 ตัว โดยคลาสใดก็ตามที่ต้องการสามารถที่ใส่ตัวเองเข้าไปใน PluginTabbedPane จะต้องทำการ implement ตัว method เหล่านี้

5. สร้าง Chart ต่าง ๆ
เราสร้างคลาสขึ้นมา 3 คลาส คือ NormalChart (NormalChart.java), PieChart (PieChart.java) และ YourChart (YourChart.java) โดยทุกคลาสทำการ implement ตัว Interface Chart ดัง cope snippet ข้างล่าง

 // NormalChart.java
 public class NormalChart extends JPanel implements Chart {
 ...
  public JComponent getChart() {
    return this;  
  }
  
  public String getName() {
    return "Normal Chart";  
  }
  
  public void init(int[] samples) {
    this.samples = samples;  
  }
  
  public void update(int[] samples) {
    this.samples = samples;
    repaint();
  }
6. สร้างคลาสหลักซึ่งใช้เป็นที่ประกอบส่วนต่าง ๆ เข้าด้วยกันทั้งหมด
เราตั้งชื่อคลาสนี้ว่าคลาส Graph (Graph.java) โดยคลาสนี้เป็นเพียงคลาสที่นำคลาสทั้งหลายมาประกอบเข้าด้วยกันเพื่อให้สมบูรณ์ รวมไปถึงโหลดคลาส Chart ต่าง ๆ เข้าไปยัง PluginTabbedPane โดยชื่อของคลาสเหล่านี้จะถูกเก็บไว้ใน Properties ไฟล์ ซึ่งเราจะไม่พูดถึงรายละเอียดในทีนี้

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

เวลารัน ให้พิมพ์ java Graph โดยผู้อ่านสามารถแก้ไฟล์ plugins.properties เพื่อโหลดเพียง Chart ที่ต้องการขึ้นมาได้
Note: ถ้าในระหว่างการรันโปรแกรมเกิด Exception อย่างข้างล่าง

>java Graph
Loading chart - NormalChart ...
java.lang.ClassNotFoundException: NormalChart
        at java.net.URLClassLoader$1.run(Unknown Source)
        at java.security.AccessController.doPrivileged(Native Method)
        at java.net.URLClassLoader.findClass(Unknown Source)
        at java.lang.ClassLoader.loadClass(Unknown Source)
        at sun.misc.Launcher$AppClassLoader.loadClass(Unknown Source)
        at java.lang.ClassLoader.loadClass(Unknown Source)
        at Graph.getPlugins(Graph.java:54)
        at Graph.initialize(Graph.java:16)
        at Graph.main(Graph.java:73)
วิธีแก้: ให้ทำการคอมไพล์ไฟล์ NormalChart.java เสียก่อน
เหตุผล: คลาส Graph ไม่มี dependency เกี่ยวข้องกับคลาส NormalChart เพราะคลาส Graph จะคุยกับคลาสที่ทำหน้าที่เป็น Chart ผ่านทาง Interface Chart ดังนั้นเวลาเราคอมไพล์คลาส Graph (Graph.java) คลาส NormalChart (NormalChart.java) จะไม่ถูกคอมไพล์รวมไปด้วย

สำหรับผู้อ่านที่เขียนคลาส Chart อื่น ๆ เพิ่มเติมและต้องการให้โลกได้รับรู้ : ) สามารถส่งคลาสดังกล่าวมารวมไว้ในดาวโหลดแพกเกจได้ โดยเพียงอีเมล์มาที่ Add My Chart!

Resources
ไฟล์ที่เกี่ยวข้องทั้งหมดสำหรับหัวข้อ Quiz1 และ Graph สามารถดาวโหลดได้จากที่นี่ Quiz1.zipGraph.zip
ถ้าใครอยากดู source code แบบออนไลน์ ก็สามารถดูได้จากที่นี่ ViewSource
สำหรับผู้อ่านที่สนใจเกี่ยวกับคอบเซ็ปของ Interface สามารถศึกษารายละเอียดเพิ่มเติมได้จาก
Artima.com เจ้าของเวป (Bill Venners) เป็นคนเขียนบทความเองทั้งหมด เนื้อหาดีมากเหมาะสำหรับการปูพื้นฐาน
java.sun.com มีเนื้อหาเกี่ยวกับ Interface อยู่เล็กน้อย :-X


Copyright © 2000-2002 www.jarticles.com