2012-11-09

在 Spring MVC 3.1.2.RELEASE 與 Hibernate 4.1.7.Final 實作變動數量的值物件(Variable Component)

值物件?以前在 Hibernate 叫做 Component,現在 JPA 叫做 Embeddable,或者從 Annotation 的角度叫做 Element。

簡單講就是沒有 Primary Key(PK) 的資料,必須依附在有 PK 的資料下。

舉例來說,每個人(Person)有好幾種地址(Address),住家地址、公司地址、戶籍地與出生地等等,這些地址資料只對一個人有意義,脫離這個人就沒有存在的必要,或者說不能與其他人共用,既然不能共用,那就沒必要有 PK,與這個人共存亡。

Person.java & Address.java
@Entity(name = "person")
@SuppressWarnings("serial")
public class Person implements Serializable {

  private Integer id;
  private String name;
  private List<Address> addressList = new ArrayList<Address>();
  /** 供 Spring Form Binding 使用 */
  private Map<Integer, Address> addressMap = new HashMap<Integer, Address>();

  @Id
  @GeneratedValue(strategy = GenerationType.AUTO)
  public Integer getId() {
    return this.id;
  }

  public void setId(Integer id) {
    this.id = id;
  }

  @Column(nullable = false)
  public String getName() {
    return this.name;
  }

  public void setName(String name) {
    this.name = name;
  }

  @ElementCollection
  @CollectionTable(name = "address", joinColumns = @JoinColumn(name = "person_id"))
  public List<Address> getAddressList() {
    return this.addressList;
  }

  public void setAddressList(List<Address> addressList) {
    this.addressList = addressList;
  }

  @Transient
  public Map<Integer, Address> getAddressMap() {
    return this.addressMap;
  }

  public void setAddressMap(Map<Integer, Address> addressMap) {
    this.addressMap = addressMap;
  }
}

@Embeddable
public class Address {

  private String type;
  private String country;
  private String city;
  private String address;

  public String getType() {
    return this.type;
  }

  public void setType(String type) {
    this.type = type;
  }

  public String getCountry() {
    return this.country;
  }

  public void setCountry(String country) {
    this.country = country;
  }

  public String getCity() {
    return this.city;
  }

  public void setCity(String city) {
    this.city = city;
  }

  public String getAddress() {
    return this.address;
  }

  public void setAddress(String address) {
    this.address = address;
  }
}
這裡面看似平凡,卻有很多辛酸在。

最奇怪的在於,多了一個 addressMap,這是因為「變動數量」這個需求造成的,稍後說明。

再來是 addressList,以前在使用 Entity 一對多的時候,習慣加上建議都是用 Set 來裝,只有少數狀況會用 List,來到 Component 的世界時,自然的也用 Set 來裝,一方面因為沒有順序的需求,二方面可以少掉一個 IndexColumn,但是,惡夢從此開始...

用 Set 寫好程式之後,執行時發現  log 裡有怪怪的問題。

第一,每次撈出 addressSet 時,Hibernate 都會吐出先 delete 再 insert 的 sql 來,怪了,我只是撈資料,又不是改資料。

這個問題,睜一隻眼閉一隻眼,還可以裝作沒看到,但是另一個問題就不行了。

第二,一開始 Address 的每個欄位都有輸入資料,到後來要測試欄位驗證時,就刻意一些欄位不輸入資料,接著,霹靂星球就爆炸了!

當 Address 有些 Null 欄位時,每讀取一次,Address 資料數量就增加一倍,啊,我只是讀取,你是想怎樣啦!

後來發現 Hibernate 在第一點說的先 delete 再 insert 時,居然是用所有的欄位去 delelte。
delete from address where person_id = ? and country = ? and city = ? and address = ?;
這這這...欄位有 Null 時,當然不能用 =,要用 IS NULL 啊,Hibernate 你是在起什麼肖!

不提這段,你為什麼不用 person_id 去抓就好?
delete from address where person_id = ?;
上窮碧落下黃泉,腦細胞死掉幾百萬個之後,終於發現,List 是我的銀彈,不是錢,是可以一槍斃命的銀子彈,把 Set 改成 List,所有的肖症頭都不藥而癒了,不再先 delete 再 insert,也用 FK 去刪資料了。


再來看看好學生 Spring MVC 發生什麼事了。

因為 Address 數量可以變動,在網頁上使用 Javascript  動態去增減 Address 區塊,這變動的資料要怎麼 bind 到 Person 呢?

一開始用 Set 就掛了,因為 Spring Form 目前無法 bind 資料到 Set 裡(有可能是我沒找到),只能 bind 到 Array 或 List 裡。

使用中括弧,加上索引值,就可以輕鬆 bind 到 List 裡。
<input type="text" name="addressList[1].country" size="4" maxlength="4" value="<c:out value="${a.country}"/>"  />
事情沒這麼美好,當我用 Javascript 砍掉第一筆,留下第二筆,送出表單時,慘案就發生了。

因為 Spring MVC 只收到 addressList[1],沒收到 addressList[0],結果咧?我得到一個有兩筆 Address 的 List,第二筆是正確的,第一筆是所有欄位都是 null 的 Address,哇咧?翻桌!

Array 和 List 這類有索引值的都是一個樣!

那現在是要怎樣?Hibernate 不讓我用 Set,Spring MVC 也不讓我用 Set,用 Array 和 List 會跳格!

只剩 Map 了,Spring MVC 可以 bind 到 Map,也不會有跳格的問題。

最後 Google 說,就辛苦一點,自己轉一轉囉...

在 Spring MVC 用 Map 去 bind,然後轉成 List 給 Hibernate 去存,取出來時,再將 List 轉成 Map,交給 Spring MVC 去呈現。

複製 AddressList 到 Map,供 Sping Form Binding。
private void cvtAddressList2Map(Model model, Person obj) {
  Map<Integer, Address> map = new HashMap<Integer, Address>();
  int i = 0;
  for (Address c : obj.getAddressList()) {
    map.put(i++, c);
  }
  model.addAttribute("addressMap", map);
}
第一個重點,Map 的 key 用遞增的數值,然後 Javascript 增加 Address 區塊時,再接著數值往上加,千萬避免使用到相同的數值,以免 bind 資料時發生覆蓋

第二個重點,不要將 Address 裝到 Person 的 addressMap 裡,Person 的 addressMap 要保持空的,因為若裝到 Person 的 addressMap 裡,假設裝了三筆 Address,索引值分別為 0、1 與 2,然後頁面操作時,若將 0 與 1 的 Address 刪除,另外新增第四筆 Address,索引值為 3,送進來 bind 後,你會發現,總共有四筆 Address 了,因為 Spring MVC 就是拿 Person 的 addressMap 來裝資料,它收到索引值為 2 與 3 的地址,卻不知道 0 與 1 被刪除,所以就將 2 與 3 的 Address 裝進去,原本的 2 被覆蓋,但是 0 與 1 卻還在,所以得到四筆 Address。

再來,從 Spring MVC bind 好的 Person addressMap 來復原 AddressList。
private void cvtAddressMap2List(Person obj, boolean copyAddressMap) {
  if (copyAddressMap) {
    // 記得先淨空
    obj.getAddressList().clear();
    obj.getAddressList().addAll(obj.getAddressMap().values());
  }
}
淨空後裝進來就好了。

再來看一些 JSP 程式,先倒出已有的地址資料。
<c:forEach var="c" items="${addressMap}" varStatus="status">
  <c:set var="c" value="${c.value}"/>
  <tr class="addressTR addressTR<c:out value="${status.index}"/>">
    <td valign="top">
      <input type="text" name="addressMap[<c:out value="${status.index}"/>].country" class="address_no" size="4" maxlength="4" value="<c:out value="${c.country}"/>"  />
    </td>
    <td valign="top">
      <input type="text" name="addressMap[<c:out value="${status.index}"/>].city" class="address_winner" size="5" maxlength="4" value="<c:out value="${c.city}"/>"  />
    </td>
    <td valign="top">
      <input type="text" name="addressMap[<c:out value="${status.index}"/>].address" class="address_works" size="4" maxlength="4" value="<c:out value="${c.address}"/>"  />
    </td>
    <td valign="top">
      <input type="button" class="button" value="DEL" onclick="deleteAddressTR('addressTR<c:out value="${status.index}"/>'); " />
    </td>
  </tr>
</c:forEach>
再來是用來新增 Address 區塊的範本。
<tr id="addressTemplate" style="display: none; ">
  <td valign="top">
    <input type="text" name="addressMap[CTIDX].country" class="address_country" size="4" maxlength="4"/>
  </td>
  <td valign="top">
    <input type="text" name="addressMap[CTIDX].city" class="address_city" size="4" maxlength="4"/>
  </td>
  <td valign="top">
    <input type="text" name="addressMap[CTIDX].address" class="address_address" size="4" maxlength="4"/>
  </td>
  <td valign="top">
    <input type="button" class="button" value="DEL" onclick="deleteAddressTR('addressTRCTIDX'); " />
  </td>
</tr>
最後是 Javascript。
var addressTRCnt;
$(function() {
  // 先紀錄已有的區塊數量
  addressTRCnt = $('.addressTR').length;
});

// 新增 Address 區塊
function insertAddressTR() {
  // 取得範本
  var html = $('#addressTemplate').html();
  // 包起來
  html = '<tr class="addressTR">' + html + '</tr>';
  // 將關鍵字 CTIDX 置換成新的索引值
  html = html.replace(/CTIDX/g, addressTRCnt);
  // 塞進頁面
  $('#addressTemplate').before(html);
  // 索引值加一
  addressTRCnt++;
}

// 刪除區塊
function deleteAddressTR(clazz) {
  $('.' + clazz).remove();
}

function submitForm(val) {
  // 最後,送出表單前,記得將範本的 input 都 disable,不然可是會一起送出去的
  $('#addressTemplate input').attr('disabled', true);
  $('#theForm').submit();
}
---
---
---


---
---
---

沒有留言:

張貼留言