Friday, March 18, 2005

จังหวัด-อำเภอ Component in Tapestry

เมื่อก่อนผมใช้ Struts เป็น Framework สำหรับ Web Application
ซึ่งก็ใช้ได้ดีทีเดียวแหล่ะ แต่มีอยู่จุดหนึ่งที่รู้สึกว่าเป็นจุดอ่อน
ของ struts ก็คือประเด็นเรื่อง reuse UI component
จริงๆอยู่เราสามารถทำ custom taglib ในส่วนนี้เองได้
แต่มันก็ยังขาดๆเกินๆอยู่

จนมาได้ลองใช้ Tapestry จึงได้พบคำตอบในส่วน UI Component
Tapestry เป็น Framework ที่มีรากฐานอยู่บน
Component เราจึงสามารถ reuse component
ต่างๆได้เต็มเม็ดเต็มหน่วย (แน่นอนมีสิ่งที่ต้องแลกเปลี่ยนเหมือนกัน)

framework ของ Tapestry เปิดโอกาสให้เรา
ได้ทำ binding ทั้ง 2 ทาง คือ ทั้งขา Request และ
ขา Response ซึ่งต่างจาก Taglib ที่ทำ binding
เฉพาะในส่วนการ Render อย่างเดียว (เมื่อ user post กลับมา
ตัว taglib จะไม่ได้ถูกเรียกใช้ แต่กรณีถ้าเป็น tapestry
ตัว component จะถูกเรียกใช้ ซึ่งในการเรียกใช้ครั้งนี้
ไม่ได้เพื่อทำการ render แต่เพื่อการ parse request parameter)

ลองมาดูกันว่า กรณีที่เป็น Tapestry Component แล้ว
เราต้องทำอะไรบ้าง

อำเภอ จังหวัด component
หลายคนคงเคยทำ ui ในส่วน "ที่อยู่" ที่ให้ user ป้อนมาแล้ว
หลักการง่ายๆก็คือ มี list box จังหวัด กับ list box อำเภอ
เมื่อ user เลือกจังหวัด list box ของอำเภอก็จะเปลี่ยน choice ตาม
การเปลี่ยนค่านี้กำหนดให้ใช้ XmlHttpRequest เพื่อไม่ให้เกิดการ
update page ทั้งหมด
โดยผมจะลองแสดงให้ดูว่าถ้าเป็น tapestry component
แล้วจะต้องทำอย่างไร

กำหนดให้ชื่อ component ว่า ZoneComponent

Component Specification
ตั้งชื่อว่า ZoneComponent.jwc มีหน้าตาดังนี้
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE component-specification
PUBLIC "-//Apache Software Foundation//Tapestry Specification 3.0//EN"
"http://jakarta.apache.org/tapestry/dtd/Tapestry_3_0.dtd">
<!-- generated by Spindle, http://spindle.sourceforge.net -->

<component-specification
class="sdf.web.comps.ZoneProvince"
allow-body="no" allow-informal-parameters="yes">

<parameter name="province" direction="form"
type="sdf.domain.Province" required="yes"/>
<parameter name="zone" direction="form"
type="sdf.domain.Zone" required="yes"/>
<parameter name="serviceUrl" direction="in"
type="java.lang.String" required="yes"/>
<parameter name="serviceMethod" direction="in"
type="java.lang.String" required="yes"/>
<parameter name="disabled" direction="in"
type="boolean" required="no"/>

<property-specification name="name"
type="java.lang.String"/>
<property-specification name="form"
type="org.apache.tapestry.IForm"/>

<private-asset name="script"
resource-path="/sdf/web/comps/ZoneProvince.script"/>
</component-specification>

วัตถุประสงค์หลักๆของ component-spec ก็คือ
การกำหนดว่า component เรามี parameter อะไรให้ set ได้บ้าง
(คนที่ set ก็คือ คนที่เอา component เราไปร้อยเข้าเป็น Application)
ตัวที่น่าสนใจก็คือ parameter name="province"
type="sdf.domain.Province" -> อันนี้คือ business object ของจังหวัด
direction="form" -> ระบุว่าเป็น bi-direction ใช้ทั้งกรณี Request และ Response
ส่วนตัว serviceUrl, serviceMethod เป็นตัวกำหนดค่า url ของ service
ที่ให้บริการกรณีที่ user click เปลี่ยนจังหวัดและต้องการนำค่า อำเภอใหม่
ไป update "อำเภอ listbox" ค่าพวกนี้มี Direction เป็น "in"
เนื่องจากเป็นพวก readonly ไม่มีการ post back กลับมาจาก user

JavaScript Template
เนื่องจากเราใช้ XMLHttpRequest เป็น channel ในการ update listbox
ดังนั้นต้องมีการใช้ javascript เข้ามาช่วย
Tapestry วางแนวทางการ embed javascript ไว้แล้วโดย
ให้เราใช้ Javascript template ในการ define javascript
(เนื่องจาก component เราจะถูกประกอบทีหลัง ดังนั้นจะมี
ค่าบางค่าที่ยังไม่รู้ว่าจะเป็นอะไร อย่างเช่นชื่อของ component
จึงต้องออกแบบให้เป็น template
เมื่อรู้ค่าที่แน่นอนแล้วจึงจะสามารถสร้างออกมาเป็น javascript
ที่ใช้งานได้จริงๆ การทำเช่นนี้ทำให้ไม่เกิดปัญหา naming comflict
กรณีที่เรามี instance ของ component มากกว่า 1 instance ใน
page เดียวกัน เช่นกรณีที่อยู่ที่ใน 1 หน้าอาจมีทั้ง ที่ทำงาน ที่ติดต่อ สถานที่เกิด)

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE script PUBLIC
"-//Apache Software Foundation//Tapestry Script Specification 3.0//EN"
"http://jakarta.apache.org/tapestry/dtd/Script_3_0.dtd">

<script>
<include-script resource-path="jsrsClient.js"/>

<input-symbol key="provinceName"
class="java.lang.String" required="yes"/>
<input-symbol key="zoneName"
class="java.lang.String" required="yes"/>
<input-symbol key="formName"
class="java.lang.String" required="yes"/>
<input-symbol key="serviceUrl"
class="java.lang.String" required="yes"/>
<input-symbol key="serviceMethod"
class="java.lang.String" required="yes"/>
<input-symbol key="scriptChangedMethod"
class="java.lang.String" required="yes"/>

<let key="populateZone" unique="yes">
populateZone_${zoneName}
</let>


<body>
function clearDropDown (selField)
{
while (selField.options.length > 0)
selField.options[0] = null;
}

function ${populateZone} (valueTextStr)
{
var selField = document.${formName}.${zoneName};
clearDropDown(selField);

// create an array of each item for the dropdown
var aPairs = valueTextStr.split(";");
if (valueTextStr.substr(valueTextStr.length - 1) == ';') {
aPairs[aPairs.length - 1] = null;
aPairs.length--;
}
// Fill 'er up
for (var i=0; i <; aPairs.length; i++) {
// each item is a "value,name" pair
aValueText = aPairs[i].split(",");
oItem = new Option;
oItem.value = aValueText[0];
oItem.text = aValueText[1];
selField.options[selField.options.length] = oItem;
}
selField.options.selectedIndex = 0;
}

function ${scriptChangedMethod}()
{
jsrsExecute("${serviceUrl}", ${populateZone},
"${serviceMethod}",
document.${formName}.${provinceName}
.options[document.${formName}
.${provinceName}.selectedIndex].value);
}
</body>

จะเห็นว่ามี element หลักๆ อยู่ 3 กลุ่ม
  • <include-script> ตัวนี้จะเป็นตัวบอกว่ามี js library ตัวไหนที่ต้อง load มาบ้าง
    กรณีของเราเราใช้ jsrsClient.js

  • <input-symbol> ใช้กำหนดค่าตัวแปรที่ต้องถูก pass เข้ามา ณ ตอนที่
    generate javascript

  • <body> เป็น script หลักในการทำงาน
    จะเห็นได้ว่าตัวแปรที่ยังไม่รู้ค่าจะอยู่ในรูป ${xxxxx}


Component Class
ตรงนี้จะเขียน code หลักในส่วนของการ render และ
การ parse Request parameter เมื่อ user post form กลับเข้ามา

  • กรณีการ render component จะหน้าตาประมาณนี้

  Body body = Body.get(cycle);

if (body == null)
throw new ApplicationRuntimeException(
Tapestry.format("must-be-contained-by-body",
"ZoneProvince"),
this,
null,
null);

Map symbols = new HashMap();

symbols.put(SYM_PROVINCE_NAME, provinceName);
symbols.put(SYM_ZONE_NAME, zoneName);
symbols.put(SYM_FORMNAME, form.getName());
symbols.put(SYM_SERVICE_METHOD, getServiceMethod());
symbols.put(SYM_SERVICE_URL, getServiceUrl());
symbols.put(SYM_SCRIPT_CHANGED_METHOD, scriptChangedMethod);

// generate ส่วน JavaScript
_script.execute(cycle, body, symbols);


writer.begin("select");
writer.attribute("name", zoneName);
renderInformalParameters(writer, cycle);

String provCode = "10"; // default to bangkok
if (getProvince() != null) {
provCode = getProvince().getCode();
}
String zoneCode = null;
if (getZone() != null) {
zoneCode = getZone().getCode();
}

// render zone list
List list = svr.findZone(provCode,
IConstants.ORDER_BY_NAME);
for (Iterator iter = list.iterator(); iter.hasNext();) {
Zone zone = (Zone) iter.next();
writer.begin("option");
writer.attribute("value", zone.getCode());
if (zoneCode != null &&
zone.getCode().equals(zoneCode)) {
writer.attribute("selected", true);
}
writer.closeTag(); // close option tag
writer.printRaw(zone.getName());
writer.end("option");
}
writer.end("select");

// render province list
writer.begin("select");
writer.attribute("name", provinceName);
writer.attribute("onchange",
"javascript:" + scriptChangedMethod + "()");
renderInformalParameters(writer, cycle);

list = svr.findAllProvince(IConstants.ORDER_BY_NAME);
for (Iterator iter = list.iterator(); iter.hasNext();) {
Province province = (Province) iter.next();
writer.begin("option");
writer.attribute("value", province.getCode());
if (province.getCode().equals(provCode)) {
writer.attribute("selected", true);
}
writer.closeTag(); // close option tag
writer.printRaw(province.getName());
writer.end("option");
}

writer.end("select");


  • กรณีการ parse parameter เมื่อมีการ post กลับมา

  String provinceCode = cycle.getRequestContext()
.getParameter(provinceName);
String zoneCode = cycle.getRequestContext()
.getParameter(zoneName);

if (Tapestry.isBlank(provinceCode)) {
return;
} else {
Province value = svr.getProvince(provinceCode);
setProvince(value);
}

if (Tapestry.isBlank(zoneCode)) {
return;
} else {
Zone value = svr.getZone(zoneCode);
setZone(value);
}

มี code หลักๆ เท่านี้ก็ได้ component สำหรับ reuse ใช้ใน Application
เราแล้ว คราวนี้ไม่ว่าจะเป็น ที่อยู่, ที่ติดต่อ, บ้านเกิด, ที่ทำงาน ก็สามารถ
ใช้ component ชุดเดียวกันได้

ตัวอย่างเวลานำไปใช้
  • html file

    <td valign="top" class="label"><div 
align="right">ที่อยู่ :</div></td>
<td><table width="100%" border="0">
<tr>
<td><input jwcid="addr1@ValidField"
value="ognl:firm.address.addr1"
displayName="ที่อยู่"
validator="ognl:beans.addr1RequireValidator"
type="text" size="50" maxlength="50"/></td>
</tr>
<tr>
<td><input jwcid="@TextField"
value="ognl:firm.address.addr2"
type="text"
size="50"
maxlength="50"/></td>
</tr>
<tr>
<td><span jwcid="zoneProvince">
zone components
</span></td>
</tr>
</table></td>

จะเห็นว่าใช้แค่ jwcid="zoneProvince" เป็นการบอกว่า
ตรงนี้เป็น component ที่มีชื่อ instance ว่า zoneProvince
ซึ่งจะมีการ declare ไว้ใน Page Specification อีกที
  • Page Specification file

    <component id="zoneProvince" type="ZoneProvince">
<binding name="province"
expression="firm.address.province"/>
<binding name="zone"
expression="firm.address.zone"/>
<static-binding name="serviceUrl"
value="/sdf/zone"/>
<static-binding name="serviceMethod"
value="getZone"/>
</component>

จะเห็นว่าตรงนี้จะมีการ binding parameter เข้ากับ Page Object
โดยระบุตรง expression คำว่า "firm.address.province" หมายถึง
ให้ไปเอา object provice มาโดยใช้คำสั่ง
getFirm().getAdress().getProvince() กับ Page Object

Note: ต้วอย่างการนำไปใช้ที่แสดงให้ดู
เป็นการเขียนแบบ formal ถ้าเป็นแบบ informal code
จะกระทัดรัดกว่านี้ แต่จะอ่านยากกว่าถ้าไม่เคยใช้
tapestry มาก่อน


ข้อมูลเพิ่มเติม

Related link from Roti

No comments: