ป้ายกำกับ

วันอังคารที่ 1 กรกฎาคม พ.ศ. 2557

ข้อควรระวังในการใช้ Static References เมื่อเขียน API

ช่วงนี้ผมกำลังเขียนโปรแกรมเกี่ยวกับ Hadoop และ HBase อยู่ และกะว่าจะทำเป็น API สำหรับใช้งานกับโปรแกรมอื่นๆได้ด้วย บังเอิญว่า ผมต้องการใช้ Logging เพื่อให้สะดวกต่อการนำไปใช้และตรวจสอบปัญหา ซึ่งเมื่อ 10 กว่าปีก่อน ผมก็เคยศึกษามาบ้าง แต่เนื่องจากไม่ค่อยได้ใช้ จึงไม่ได้ลงลึกอะไรมากนัก แต่ตอนนี้ผมต้องเลือกว่าจะใช้ Framework ตัวไหนดี ที่ผมมองๆอยู่ก็มี 4 ตัว คือ
  1. Java Logging ซึ่งเป็นมาตรฐานหลักเพราะมีอยู่ใน JRE อยู่แล้ว ถ้าเอามาใช้ก็จะได้ไม่ต้องพ่วง Library อะไรให้ยุ่งยาก แต่ Java Logging ไม่ค่อยจะเก่งสักเท่าไหร่ การใช้งานจึงไม่สะดวกนัก
  2. Apache log4j ซึ่งเป็น Logging API ที่มีมานานมาก ถ้าจำไม่ผิดน่าจะก่อน Java Logging เสียอีก และเท่าที่ได้กลับไปดูหน้าเว็บของโครงการ Apache Logging Services พบว่ามีการพัฒนาไปเป็น Apache log4php log4net และ log4cxx อีกด้วย
  3. Apache หรือ Jakarta Commons Logging (JCL) มีแนวคิด ซึ่งออกแบบมาให้เป็น abstract หรือ wrapper เพื่อให้ผู้ใช้สามารถเลือกตัว Logger ที่ต้องการใช้งานจริงๆได้ ถอดเปลี่ยนได้ จึงสามารถใช้งานร่วมกับ Logger ตัวอื่นๆได้ เช่น log4j, Java Logging และ Avalon LogKit
  4. Simple Logging Facade for Java (SLF4J) ตัวนี้ HBase ใช้งานอยู่ ออกแบบมาให้เป็น abstract หรือ wrapper เช่นเดียวกับ JCL สามารถใช้งานร่วมกับ Logger ตัวอื่นๆได้ เช่น log4j, Java Logging และ Logback
แต่ประเด็นที่ผมนำมาเขียนบทความนี้ไม่ใช่ว่าจะเลือกตัวไหนดีนะครับ ประเด็นก็คือปัญหาเรื่องการใช้ static ในการสร้างตัว Logger ซึ่งมักจะทำไปเพราะเป็นที่คุ้นเคย เช่น
public class Root {
    private static Log log = LogFactory.getLog(Root.class);
    ....
}
การประกาศให้ตัว Logger เป็น static (class variable) จะช่วยให้ไม่สิ้นเปลือง เพราะมี reference เพียงแค่ตัวเดียวในระบบ ผิดกับการสร้าง Logger ที่ไม่เป็น static (instance variable) ทั้งนี้ ประสิทธิภาพจะแตกต่างกันมากน้อยก็ขึ้นอยู่กับว่า ในระบบของเรามี instance เกิดขึ้นมากแค่ไหน และมีการเรียกใช้ Logger บ่อยแค่ไหนด้วย

Stand-Alone Application

ประเด็นปัญหาของการสร้าง Logger เป็น static จะไม่เกิดขึ้นเลยหากโค้ดที่เราเขียนเป็น Stand-alone Application แต่จะเป็นปัญหาก็ต่อเมื่อ โค้ดที่เราเขียนเป็น API และถูกนำไปใช้งานโดย Application หลายๆตัวที่ทำงานอยู่บน Container เดียวกัน เช่น Web Application เนื่องจาก Web Server อย่าง Tomcat (Servlet Container) จะมีตัว ClassLoader หลายตัวในการโหลด Web Application แต่ละตัวขึ้นมา ซึ่งทำให้ Application แต่ละตัวใช้ Logger ตัวเดียวกันได้ (เนื่องจากเป็น static) เกิดปัญหาว่าข้อมูล Log ของ Application แต่ละตัวจะปะปนกัน ไม่สามารถแยกออกได้

ปัญหานี้ไม่ใช่จะเกิดขึ้นกับเฉพาะ Logger เท่านั้น แต่จะเกิดกับตัวแปร static อื่นๆด้วยเช่นกัน จึงทำให้ผมต้องคิดหนักขึ้นกับกรณีอื่นๆ และเป็นที่มาของการทดลอง เพื่อแสดงให้เห็นถึงผลลัพธ์ที่เกิดขึ้นกับปัญหานี้

CLASSPATH and ClassLoader

ก่อนอื่นขออธิบายเรื่อง ClassLoader สักนิดหนึ่ง เผื่อว่าบางคนอาจจะยังไม่เข้าใจนะครับ (สำหรับคนที่เข้าใจเรื่องนี้แล้วก็ข้ามหัวข้อนี้ไปได้เลย) การโหลดโปรแกรมมาทำงานของ java (Java Virtual Machine, JVM) จะทำโดย ClassLoader ซึ่ง ClassLoader หลักคือ Bootstrap ClassLoader จะทำการอ่านคลาสต่างๆที่จำเป็น และค้นหาคลาสไปตามโฟลเดอร์ต่างๆใน CLASSPATH ที่ตั้งไว้ โปรแกรมใดต้องการใช้คลาสอะไรบ้างก็ต้องระบุที่อยู่ของมันไว้ใน CLASSPATH ให้ถูกต้อง มิฉะนั้นจะเกิด ClassNotFoundException ขึ้นและโปรแกรมก็จะทำงานไม่ได้ นี่คือหลักปกติ ทุกอย่างจะต้องเตรียมไว้ให้ครบถ้วนก่อนที่จะรันโปรแกรม

แต่ในความเป็นจริงแล้ว กลไกนี้สามารถเพิ่มเติมหรือขยายขอบเขตของโปรแกรมออกไปได้ด้วยการสร้าง ClassLoader เพิ่มเติมในโปรแกรมของเรา อันนี้เป็นความพิเศษของภาษา Java เรียกว่า Dynamic Class Loading จะทำให้โปรแกรมมีความยืดหยุ่นสูง และการทำ Plug-in เป็นไปได้ง่ายมาก (รายละเอียดศึกษาเพิ่มเติมในเรื่อง ClassLoader และเรื่อง Reflection) เมื่อโปรแกรมของเราทำงานและมีการสร้าง ClassLoader เพิ่มเติมขึ้นมา โปรแกรมของเราก็จะสามารถโหลดคลาสใหม่ๆเข้ามาทำงานโดยที่คลาสนั้นๆไม่จำเป็นต้องอยู่ใน CLASSPATH และเราไม่จำเป็นต้องหยุดการทำงานของโปรแกรมเพื่อจัดการและรันใหม่ โดยที่กลไกการสร้าง ClassLoader นั้นจะกำหนดให้เราต้องสร้างและเชื่อมต่อกันเป็นทอดๆ กลายเป็น Tree ของ ClassLoader โดยมี Bootstrap ClassLoader เป็นจุดบนสุด (root) ของ Tree หาก ตัว ClassLoader ใด ต้องการหาคลาสที่จะโหลด จะต้องเรียกไปใหั Parent ของมันค้นหาก่อน และส่งต่อกันเป็นทอดๆ จนไปสุดที่จุดสูงสุดของ Tree หากหาไม่พบจึงจะค่อยๆหาย้อนกลับมาจนหมดแล้วจึงเกิด ClassNotFoundException แต่ถ้าหาพบโดย ClassLoader ตัวใด คลาสนั้นก็จะถูกโหลดมาด้วย CassLoader นั้นๆ นั่นหมายความว่า หากมีคลาสวางอยู่ใน CLASSPATH แม้เราตั้งใจจะให้โหลดจากที่อื่นก็จะทำไม่ได้ (ยกเว้นว่าเราจะสร้าง ClassLoader แบบของเราเองและไม่ทำตามกฎ)

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

Preparations

source code ของโปรแกรมทดสอบ สามารถดาวโหลดได้ที่ Google Drive ไฟล์ที่ดาวโหลดมา เป็น zip ของโฟลเดอร์โปรเจ็คใน Eclipse เมื่อแตกไฟล์มาแล้ว ในโฟลเดอร์ src มี source code ทั้งหมดด้วยกัน 4 ไฟล์คือ
  1. StaticFieldTest.java เป็น main โปรแกรมที่จะรันทดสอบ
  2. test/App1.java เป็นคลาสที่จำลองว่าเป็น Application ตัวที่หนึ่ง
  3. test/App2.java เป็นคลาสที่จำลองว่าเป็น Application ตัวที่สอง
  4. test/StaticField.java เป็นคลาสที่จำลองว่าเป็น Library ที่ทั้งสอง Application เรียกใช้งาน
ก่อนอื่นต้องขออธิบายโปรแกรมหลักก่อนนะครับ ไฟล์ StaticFieldTest.java เป็นดังนี้

import java.io.IOException;
import java.net.URL;
import java.net.URLClassLoader;

public class StaticFieldTest {
  public static void main(String[] args) throws Exception {
    System.out.println("SystemClassLoader: "+ClassLoader.getSystemClassLoader());
    ClassLoader sftCL = StaticFieldTest.class.getClassLoader();
    System.out.println("StaticFieldTest ClassLoader: "+sftCL);
    System.out.println("StaticFieldTest ParentClassLoader: "+sftCL.getParent());
    URLClassLoader shareCL = new URLClassLoader(new URL[] { new URL("file:///tmp/test/share/") });
    URLClassLoader app1CL = new URLClassLoader(new URL[] { new URL("file:///tmp/test/app1/") }, shareCL);
    URLClassLoader app2CL = new URLClassLoader(new URL[] { new URL("file:///tmp/test/app2/") }, shareCL);
    Object obj1 = app1CL.loadClass("test.App1").newInstance(); // Load app1
    Runnable app1 = (Runnable)obj1;
    app1.run(); // Run app1
    Object obj2 = app2CL.loadClass("test.App2").newInstance(); // Load app2
    Runnable app2 = (Runnable)obj2;
    app2.run(); // Run app2
  }
}

โปรแกรมนี้จำลองการทำงานว่าตัวเองเป็น Application Container กล่าวคือ โปรแกรมจะโหลด Application App1 และ App2 เข้ามารันผ่านทาง URLClassLoader ในที่นี้ผมสร้างเตรียมไว้ 3 ตัวคือ shareCL เป็นตัวโหลดคลาสต่างๆที่อยู่ในโฟลเดอร์ /tmp/test/share ส่วน app1CL และ app2CL จะโหลดคลาสต่างๆที่อยู่ในโฟลเดอร์ /tmp/test/app1 และ /tmp/test/app2 ตามลำดับ โดยที่ app1CL และ app2CL จะถูกสร้างให้มี parent คือ shareCL หากใครจะทดลองตามก็ขอให้สร้างโฟลเดอร์นี้ไว้ หรือแก้ไข source code ไปที่โฟลเดอร์ที่ต้องการก่อนนะครับ

หากใครไม่ได้ใช้ Eclipse ผมก็ขอแนะนำ command-line สำหรับ compile โปรแกรมง่ายๆคือ
cd StaticFieldTest
javac -d bin src/*.java src/test/*.java
จะได้ .class ไฟล์ที่โฟลเดอร์ bin ซึ่งประกอบไปด้วย 4 ไฟล์คือ
  1. StaticFieldTest.class
  2. test/App1.class
  3. test/App2.class
  4. test/StaticField.class

การทดลองที่ 1: Stand-alone Application

เมื่อ compile เสร็จแล้ว เราจะรันโปรแกรมโดยมีทุกๆคลาสอยู่ใน CLASSPATH (มองจาก working dir) นั่นคือ หากรันโปรแกรมด้วยคำสั่ง
cd bin
java StaticFieldTest
จะได้ผลลัพธ์ดังนี้

SystemClassLoader: sun.misc.Launcher$AppClassLoader@1978b0f9
StaticFieldTest ClassLoader: sun.misc.Launcher$AppClassLoader@1978b0f9
StaticFieldTest ParentClassLoader: sun.misc.Launcher$ExtClassLoader@42aab87f
Running App1
App1 ClassLoader: sun.misc.Launcher$AppClassLoader@1978b0f9
App1 ParentClassLoader: sun.misc.Launcher$ExtClassLoader@42aab87f
Static Initialize StaticField
StaticField ClassLoader: sun.misc.Launcher$AppClassLoader@1978b0f9
StaticField ParentClassLoader: sun.misc.Launcher$ExtClassLoader@42aab87f
StaticField.getSvar(): 0
End of Initialize StaticField
StaticField.getSvar(): 0
StaticField.setSvar(10): 10
StaticField.increaseSvar(): 11
End of App1
Running App2
App2 ClassLoader: sun.misc.Launcher$AppClassLoader@1978b0f9
App2 ParentClassLoader: sun.misc.Launcher$ExtClassLoader@42aab87f
StaticField.getSvar(): 11
StaticField.setSvar(10): 10
StaticField.increaseSvar(): 11
End of App2

กรณีนี้จะเหมือน Stand-alone Application นั่นคือ Bootstrap ClassLoader จะเป็นตัวโหลดคลาสต่างๆเข้ามา เนื่องจากมีคลาสต่างๆอยู่ใน CLASSPATH  อยู่แล้ว shareCL, app1CL และ app2CL ส่งการค้นหามาให้ Parent โหลดก่อน ClassLoader ของ App1, StaticField และ App2 จึงเป็นตัวเดียวกัน โดยผลลัพธ์แสดงให้เห็นว่า App1 และ App2 ใชั StaticField ร่วมกันโดยสังเกตได้จากค่า StaticField เริ่มต้น initialize เพียงครั้งเดียวจากค่า 0 และ App1 เซ็ตค่าให้เป็น 10 จากนั้นก็เพิ่มเป็น 11 ส่วน App2 ก็เริ่มที่ค่า 11 จากผลของ App1 และเซ็ตค่าเป็น 10 แล้วก็เพิ่มค่าเป็น 11

การทดลองที่ 2: ย้าย App1 และ App2 และ Share ไปไว้คนละที่

ทำการย้ายไฟล์ test/App1.class ไปไว้ที่ /tmp/test/app1/test/App1.class ย้าย test/App2.class ไปไว้ที่ /tmp/test/app2/test/App2.class และ test/StaticField.class ไปไว้ที่ /tmp/test/share/test/StaticField.class สังเกตว่า เราจะต้องย้ายไปทั้ง package นะครับ นั่นคือแต่ละคลาสอยู่ใน package test ก็เลยต้องมีโฟลเดอร์ย่อยชื่อ test ติดไปด้วย ไม่อย่างนั้นจะเกิด ClassNotFoundException ครับ เมื่อรันเหมือนเดิม คือ java StaticFieldTest ที่โฟลเดอร์ bin เหมือนเดิม จะได้ผลลัพธ์ดังนี้

SystemClassLoader: sun.misc.Launcher$AppClassLoader@1978b0f9
StaticFieldTest ClassLoader: sun.misc.Launcher$AppClassLoader@1978b0f9
StaticFieldTest ParentClassLoader: sun.misc.Launcher$ExtClassLoader@42aab87f
Running App1
App1 ClassLoader: java.net.URLClassLoader@27021e58
App1 ParentClassLoader: java.net.URLClassLoader@7c163769
Static Initialize StaticField
StaticField ClassLoader: java.net.URLClassLoader@7c163769
StaticField ParentClassLoader: sun.misc.Launcher$AppClassLoader@1978b0f9
StaticField.getSvar(): 0
End of Initialize StaticField
StaticField.getSvar(): 0
StaticField.setSvar(10): 10
StaticField.increaseSvar(): 11
End of App1
Running App2
App2 ClassLoader: java.net.URLClassLoader@5f0a94c5
App2 ParentClassLoader: java.net.URLClassLoader@7c163769
StaticField.getSvar(): 11
StaticField.setSvar(10): 10
StaticField.increaseSvar(): 11
End of App2

จะเห็นว่า App1, App2 และ StaticField ถูกโหลดมาด้วย ClassLoader คนละตัวกัน แต่เนื่องจากผมสร้างให้ StaticField โหลดมาจาก shareCL ที่ใช้ร่วมกันระหว่าง App1 และ App2 จึงทำให้ StaticField มีการ initialize เพียงครั้งเดียวเช่นเดิม และผลลัพธ์ค่าของ StaticField ยังคงเหมือนเดิม คือ App1 กำหนดค่าให้เป็น 11 แล้วพอมาที่ App2 ก็เป็นค่า 11

การทดลองที่ 3: App1 และ App2 ใช้ StaticField คนละตัวกัน

ทำการก็อปปี้ /tmp/test/share/test/StaticField.class ไปไว้ยัง /tmp/test/app1/test/StaticField.class และ /tmp/test/app2/test/StaticField.class  แยกเป็นสองไฟล์ในแต่ละโปรแกรม จากนั้นลบไฟล์ /tmp/test/share/test/StaticField.class ทิ้งไป เพื่อไม่ให้ shareCL สามารถโหลดคลาสมาได้ เมื่อรันเหมือนเดิม คือ java StaticFieldtest ที่โฟลเดอร์ bin เหมือนเดิม จะได้ผลลัพธ์ดังนี้

SystemClassLoader: sun.misc.Launcher$AppClassLoader@6521f956
StaticFieldTest ClassLoader: sun.misc.Launcher$AppClassLoader@6521f956
StaticFieldTest ParentClassLoader: sun.misc.Launcher$ExtClassLoader@1978b0f9
Running App1
App1 ClassLoader: java.net.URLClassLoader@6d15a113
App1 ParentClassLoader: java.net.URLClassLoader@27021e58
Static Initialize StaticField
StaticField ClassLoader: java.net.URLClassLoader@6d15a113
StaticField ParentClassLoader: java.net.URLClassLoader@27021e58
StaticField.getSvar(): 0
End of Initialize StaticField
StaticField.getSvar(): 0
StaticField.setSvar(10): 10
StaticField.increaseSvar(): 11
End of App1
Running App2
App2 ClassLoader: java.net.URLClassLoader@fe9e47
App2 ParentClassLoader: java.net.URLClassLoader@27021e58
Static Initialize StaticField
StaticField ClassLoader: java.net.URLClassLoader@fe9e47
StaticField ParentClassLoader: java.net.URLClassLoader@27021e58
StaticField.getSvar(): 0
End of Initialize StaticField
StaticField.getSvar(): 0
StaticField.setSvar(10): 10
StaticField.increaseSvar(): 11
End of App2

เราจะพบว่า StaticField ที่เรียกใช้โดย App1 กับ App2 กลายเป็นคนละตัวกัน และแยกจากกันโดยสิ้นเชิง ทำให้การ initialize static field และการกำหนดค่า static field ของแต่ละโปรแกรม แยกจากกันโดยเด็ดขาดได้

สรุป

หากต้องการจะหลีกเลี่ยงปัญหาที่ static field มีการใช้งานร่วมกันระหว่างโปรแกรมหลายๆโปรแกรมที่ถูกโหลดมาจาก ClassLoader แตกต่างกันแล้วไม่สามารถควบคุมให้แยกจากกันได้ วิธีการแก้ก็คือ จะต้องวางคลาสไฟล์ หรือ JAR ไฟล์ของ API ตัวนั้นไว้ที่เดียวกับโปรแกรมนั้นๆ เพื่อให้ ClassLoader ของโปรแกรมนั้นมองเห็นโดยตรง จะต้องไม่ไปวางไว้ในโฟลเดอร์ที่แชร์ร่วมกัน

References

ไม่มีความคิดเห็น:

แสดงความคิดเห็น