[[FrontPage]] #contents 2008/01/24からのアクセス回数 &counter; * Cart問題 [#c949db9e] Agile Web Development with Railsの例題と同じ問題をSpringを使って実装を試みたときの メモです。 もう一つの目的は、Spring-MVCプラグインがどの程度実際の問題解決に役立つかを検証することです。 ** プロジェクトの作成 [#y8873d2a] mavenを使ってプロジェクトを生成します。 - groupIdは、example.cart - artifactIdは、cart とします。ecliseでプロジェクトを管理できるようにeclipseプラグインも起動します。 #pre{{ mvn archetype:create \ -DgroupId=example.cart \ -DartifactId=cart \ -DarchetypeArtifactId=spring-mvc-archetype \ -DarchetypeGroupId=jp.co.pwv.spring-mvc-archetype \ -DarchetypeVersion=1.1.1 cd cart mvn eclipse:eclipse -DdownloadSources=true }} データベースは、HsqlDBのサーバを使用するため、db.propertiesの内容を修正します。 #pre{{ db.url=jdbc:hsqldb:hsql://localhost }} 最後にeclipseでcartプロジェクトをimportし、CVSに登録します。 * [[session scopeについて]] [#fddab324] 長くなったので別タイトルにしました。 * Product(製品の)の管理 [#r6b52203] 最初にProductを管理するページを作成します。 さしあたり、管理機能として - 製品の一覧表示 - 製品の追加、編集、削除 ができるようにします。 ** Productドメインモデルの作成 [#g8855bc8] 最初にProductのドメインモデルを作成します。 ドメインモデルは、example.cart.domainパッケージ内に定義します。 eclipseで以下のように入力した後、getter/setterを自動生成してください。 #pre{{ package example.cart.domain; public class Product { private Integer id; private String title; private String description; private String image_url; } }} ** GenMVCプラグインの起動 [#x97dd396] GenMVCプラグインのscaffoldゴールを指定して、ProductのDao、 Controller、 View、データベーステーブル を自動生成します。 その前に、GenMVCプラグインは、Productのクラスファイルを見に行くので、mavenのpackageを実行します。 #pre{{ mvn package mvn GenMVC:scaffold }} このコマンドで、 #pre{{ [INFO] ------------------------------------------------------------------------ [INFO] Building Unnamed - example.cart:cart:war:1.0-SNAPSHOT [INFO] task-segment: [GenMVC:scaffold] [INFO] ------------------------------------------------------------------------ [INFO] [GenMVC:scaffold] [INFO] pkgName:example.cart [INFO] runtime.classpath:/Users/take/Documents/workspace/cart/target/classes [INFO] cls: example.cart.domain.Member [INFO] template fullpath:velocity/IDao.vm [INFO] template fullpath:velocity/Dao.vm [INFO] template fullpath:velocity/edit_stub.vm [INFO] template fullpath:velocity/list_stub.vm [INFO] template fullpath:velocity/hbm.vm [INFO] cls: example.cart.domain.Product [INFO] template fullpath:velocity/IDao.vm [INFO] template fullpath:velocity/Dao.vm [INFO] template fullpath:velocity/Manager.vm [INFO] template fullpath:velocity/EditController.vm [INFO] template fullpath:velocity/OpsController.vm [INFO] template fullpath:velocity/edit.vm [INFO] template fullpath:velocity/edit_stub.vm [INFO] template fullpath:velocity/list.vm [INFO] template fullpath:velocity/list_stub.vm [INFO] template fullpath:velocity/hbm.vm [INFO] template fullpath:velocity/servlet-stub.vm [INFO] template fullpath:velocity/sql.vm [INFO] template fullpath:velocity/applicationContext.vm [INFO] template fullpath:velocity/form-messages.vm [INFO] template fullpath:velocity/validation.vm }} と出力され、必要なファイルがすべて生成されます。 再度、mvn packageを実行してtarget/cart.warをtomcatのwebappsにコピーします。 これだけで、Productのリスト表示、編集の画面が生成されます。 #ref(product_list.jpg); ** 属性の追加 [#b2ed20d1] scaffoldの後にProductに属性を追加したくなることはよくあります。 Product の属性を変更したときの手順は以下の通りです。 - テーブルの削除 - velocity/*_stub.vmファイルのバックアップ - validation.xmlファイルのバックアップ 通常は、これで十分ですが、以下のファイルを修正した場合にはバックアップを取ってください。 - webapp/WEB-INF/hbm-dir/Product.hbm.xml - main/resources/form-messages.properties 今回は、自動生成されたファイルを全く変更していないので、テーブルの削除だけを行います。 *** テーブルの削除 [#x9330700] 開発の途中ではデータベースのテーブルを変更したり、値を参照します。このような用途に便利なのが Ecl,ipseのプラグインDbEditです。 DbEditのインストール方法はhttp://www.pwv.co.jp/take_public_html/DevTool/DevTool_c9.html#doc1_589 を参照してください。 DbEditのTableタグを開くと以下のようにT_MEMBERとT_PRODUCTの2つのテーブルが作られています。 #ref(DbEdit.jpg); GenMVCプラグインでは、クラス名の前にT_を付けたテーブルが作成されます。 T_PRODUCTを削除するには、 T_PRODUCTで右マウスクリックから削除を選択してください。 - すでに沢山のデータが入っていたらどうすれば良いのか? この答えは、テストケースのデバッグで紹介する予定です。 *** Productクラスの変更 [#j28b6d69] Productに価格(price)を追加します。 以下のように属性priceを追加し、getter/setterを自動生成するだけです。 #pre{{ private Double price; }} 日本では価格に小数点はないのですが、ここでは例としてDouble型を使いました。 それでは、先ほどと同様にGenMVCプラグインを起動します。 #pre{{ mvn package mvn GenMVC:scaffold }} ** 画面(Velocityテンプレート)の変更 [#b82e17e4] GenMVCプラグインが生成する画面は、属性の出力順がProductクラスの定義順に並んでいないので、実際には手で修正する必要があります。 ProductのVelocityテンプレートは、main/webapp/WEB-INF/velocity/productops/以下にあります。 - list_stub.vm - list.vm が一覧を表示するテンプレートです。 list.vmを見ると #pre{{ parse ( "productops/list_stub.vm" ) }} だけです。 これは、GenMVCプラグインがlist.vmを変更しないようするためにlist_stub.vmをインクルードする 2段階で処理しています。 従ってユーザvelocityテンプレートを変更する場合には、list_stub.vmをlist.vmにコピーして編集します。 以下に順序を入れ替えたlist.vmを示します。 #pre{{ <html> <head> <title>Product</title> </head> <body> <h1>Listing product</h1> <table> <tr> <td>id</td> <td>title</td> <td>description</td> <td>image_url</td> <td>price</td> </tr> #foreach (${product} in ${productList}) <tr> <td>${product.id}</td> <td>${product.title}</td> <td>${product.description}</td> <td>${product.image_url}</td> <td>${product.price}</td> #set( $editLink = "/editproduct.htm?id=${product.id}" ) <td><a href="#springUrl(${editLink})">[edit]</a></td> #set( $deleteLink = "/productops/delete.htm?id=${product.id}" ) <td><a href="#springUrl(${deleteLink})">[delete]</a></td> </tr> #end </table> <a href='#springUrl("/editproduct.htm")'>add</a> </body> </html> }} 同様に編集画面も順序を変え、Descriptionをtextareaに変えてます。 #pre{{ <html> <head> <title>Products</title> </head> <body> Edit Product <form method="post" action="#springUrl("/editproduct.htm")"> #springFormHiddenInput( "product.id" "" ) <table> <tr> <td>title:</td> <td>#springFormInput( "product.title" "size='35'" )</td> <td> #springBind("product.title") <font color="red">${status.errorMessage}</font> </td> </tr> <tr> <td>description:</td> <td>#springFormTextarea( "product.description" "rows='4' cols='40'" )</td> <td> #springBind("product.description") <font color="red">${status.errorMessage}</font> </td> </tr> <tr> <td>image_url:</td> <td>#springFormInput( "product.image_url" "size='35'" )</td> <td> #springBind("product.image_url") <font color="red">${status.errorMessage}</font> </td> </tr> <tr> <td>price:</td> <td>#springFormInput( "product.price" "size='10'" )</td> <td> #springBind("product.price") <font color="red">${status.errorMessage}</font> </td> </tr> <tr> <td colspan="3"> <input type="submit" value="Save Changes"/> </td> </tr> </table> </form> </html> </body> }} ** Validationの変更 [#pda78f32] 現在の入力フォームは、各フィールドが必須だけのチェックしかしていません。 priceに文字を入力して、Save Chageボタンを押すと #ref(validation_error1.jpg); が出力されます。 これでは、エラーの原因が分かりづらいので、validation.xmlを修正してpriceをdoubleとしてふさわしい値になるようにしようと、 #pre{{ <field property="price" depends="required"> <arg0 key="product.price" /> </field> }} を #pre{{ <field property="price" depends="required,double"> <arg0 key="product.price" /> </field> }} としたが、ダメでした。 原因は、Validationが行われる前に、Productの値がHTTPのパラメータからセットされるためです。 *** Validationエラーへの対応 [#l2ea6018] ソースをトレースした結果、bindAndValidationを使用する場合、最初にHTTPリクエストから値をセットするcommandオブジェクトへのbind操作が先行します、ここでStringからDoubleへの変換に失敗するため、その後のValidationのエラーチェックでは値がセットされていないので、requiredのエラーが追加されますが、表示されません。 対応策としては、ProductionのPriceをDoubleからStringに変えるという方法しかありません。 これでは、GenMVCプラグインを起動すると間違ったCreate table文が生成されてしまいます。 *** commandクラスの追加 [#x8f77839] そこで、domainクラスに対応するcommandクラスをGenMVCプラグインで生成し、その属性をすべてString型としました。 更に、EditProductControllerでcomanndオブジェクトとdomainオブジェクトの値を変換するメソッドcommandToDomain, domainToCommandを追加しました。こんなに簡単にデータの設定ができるのは、CustomDataBinderの威力です。 #pre{{ protected void bind(Object target, Object source) throws Exception { CustomDataBinder binder = new CustomDataBinder(target, source); this.prepareBinder(binder); binder.bind(); } protected Object commandToDomain(Object source) throws Exception { Object object = new Product(); bind(object, source); return (object); } protected Object domainToCommand(Object source) throws Exception { Object object = new ProductCommand(); bind(object, source); return (object); } }} これでようやく、priceのValidationが正常に行えるようになりました。 #ref(validation_error2.jpg); *** スタイルシートの設定 [#x0bfaf64] 最後にスタイルシート使って衣装替えをします。 スタイルシートについては、詳しくないのでAgile Web Development with Railsの例題のスタイルを借用します。 スタイルシートを追加したlist.vmは、以下の通りです。 #pre{{ <html> <head> <link rel="stylesheet" href="#springUrl('/css/cart.css')" type="text/css"> <title>Product</title> </head> <body> <div id="product-list"> <h1>Listing product</h1> <table cellpadding="5" cellspacing="0"> #foreach (${product} in ${productList}) #if($velocityCount %2 == 1) <tr valign="top" class="list-line-odd"> #else <tr valign="top" class="list-line-even"> #end <td> <img class="list-image" src="#springUrl(${product.image_url})" /> </td> <td width="60%"> <span class="list-title">${product.title}</span><br/> #if ($product.description.length() > 80) #set($size = 80) #else #set($size = $product.description.length()) #end $product.description.substring(0,$size) </td> <td class="list-action"> #set( $editLink = "/editproduct.htm?id=${product.id}" ) <a href="#springUrl(${editLink})">[edit]</a><br/> #set( $deleteLink = "/productops/delete.htm?id=${product.id}" ) <a href="#springUrl(${deleteLink})">[delete]</a> </td> </tr> #end </table> <a href='#springUrl("/editproduct.htm")'>New product</a> </div> </body> </html> }} ここで、スタイルシートの指定を #pre{{ <link rel="stylesheet" href="#springUrl('/css/cart.css')" type="text/css"> }} でしているところと、説明文を一部カットするためにStringのsubstringメソッドをテンプレートから呼び出しているところに注意してください。このようにVelocityを使うとテンプレートからjavaのメソッド呼び出しができます。 スタイルシートの出力結果は、以下の通りです。 #ref(styled_list.jpg); * カタログページの作成 [#e0b5394f] 次にカタログ表示ページを作成します。Agile Web Development with Railsではカタログの表示にstoreという新しいコントローラを作成していますが、ここではProductOpsControllerを借用します。 その理由は、ProductOpsControllerがProductを扱うコントローラであり、MultiActionControllerのサブクラスなのでメソッドと同じテンプレートを作成するだけでカタログページが作れるとメリットを示すためです。 ** メソッドとテンプレートの追加 [#r8bd7e78] 手順は以下の通りです。 - ProductOpsControllerのlistメソッドをコピーしてcatalogにメソッド名を変更 - velocity/productops/list.vmをコピーしてcatalog.vmにファイル名を変更 #pre{{ public ModelAndView catalog(HttpServletRequest request, HttpServletResponse response) throws Exception { return new ModelAndView().addObject(manager.findAll()); } }} catalog.vmは以下の通りです。 #pre{{ <html> <head> <link rel="stylesheet" href="#springUrl('/css/cart.css')" type="text/css"> <title>Store</title> </head> <body id="store"> <h1>Your wine catalog</h1> #foreach (${product} in ${productList}) <div class="entry"> <img src="#springUrl(${product.image_url})" /> <h3>${product.title}</h3> $product.description <br> <span class="price">$numberTool.format("##0", $product.price)</span> </div> #end </body> </html> }} カタログページは、次のように表示されます。 #ref(catalog1.jpg); ** カートへの追加ボタンの追加 [#haf70d03] 最後にカートへの追加ボタンを入れます。 価格の後に次の行を挿入します。 #pre{{ <form method="post" action="#springUrl("/cartops/add.htm")"> #springFormHiddenInput( "product.id" "" ) <input type="submit" value="Add to Cart"/> </form> }} 画面では次のように表示されます。 #ref(catalog2.jpg); * 注文項目(LineItem)の追加 [#afe2afe0] カートの処理に進む前に注文項目を作成します。 ここでのポイントはカートの注文項目がそのまま注文にリンクされるようにすることです。 それでは、注文項目のdomainクラスを作りましょう。 #pre{{ package example.cart.domain; public class LineItem { private Integer id; private Integer quantity = new Integer(1); private Integer productId; private Product product; private Integer orderId; public void addQuantity(Integer quantity) { this.quantity += quantity; } public Double getPrice() { return (new Double(quantity*product.getPrice())); } } }} として、getter/setterを自動生成してください。 - addQuantityは、個数を追加する - getPriceは注文項目の小計を返す この後は、いつものようにGenMVC:scaffoldを実行します。 #pre{{ mvn package mvn GenMVC:scaffold }} * カートの処理 [#fa471eed] カートの処理を行う、CartServiceとカートに対する要求を処理するCartOpsControllerを追加します。 ** CartServiceの追加 [#re732448] 最初にCartServiceを追加します。CartServiceでは - lineItemMapで注文項目を管理 - lineItemの追加、合計の計算 を行います。 #pre{{ package example.cart.service; import java.util.Iterator; import java.util.Map; import java.util.TreeMap; import example.cart.domain.LineItem; public class CartService { private Map<Integer, LineItem> lineItemMap = new TreeMap<Integer, LineItem>(); public void addLineItem(LineItem item) { Integer key = item.getProductId(); LineItem lineItem = (LineItem)lineItemMap.get(key); if (lineItem == null) lineItemMap.put(key, lineItem); else lineItem.addQuantity(item.getQuantity()); } public Double getTotal() { double total = 0; Iterator itr = lineItemMap.values().iterator(); while (itr.hasNext()) { LineItem lineItem = (LineItem)itr.next(); total += lineItem.getPrice(); } return (new Double(total)); } public Map getLineItemMap() { return lineItemMap; } } }} ** CartOpsControllerの追加 [#e9b3ae9e] ProductOpsContollerをコピーしてCarOptsControllerを作成します。 - setupCartServiceでは、session scopeオブジェクトをWebApplicationContextから取得し、cartServiceに セットします #pre{{ private CartService cartService; private ProductManager manager; private void setupCartService(HttpServletRequest request) { ApplicationContext co = WebApplicationContextUtils. getRequiredWebApplicationContext( request.getSession().getServletContext()); cartService = (CartService)co.getBean("cartService"); } }} listでは、 - lineItemのリストをcartServiceから取得し、"lineItemList"の名前で登録します - テンプレートは、デフォルトでcartops/list.vmがセットされます #pre{{ public ModelAndView list(HttpServletRequest request, HttpServletResponse response) throws Exception { setupCartService(request); return new ModelAndView().addObject("lineItemList", cartService.getLineItemMap().values()); } }} addでは、 - HTTP要求からProductのidを取得し、ProductManagerを使ってProductを取得します - 新たにLineItemを生成し、productId, productをセットし、cartServiceに追加します - lineItemのリストをcartServiceから取得し、"lineItemList"の名前で登録します - テンプレートにcartops/list.vmをセットします #pre{{ public ModelAndView add(HttpServletRequest request, HttpServletResponse response) throws Exception { Integer productId = new Integer(ServletRequestUtils.getRequiredIntParameter(request, "id")); setupCartService(request); Product product = manager.findById(productId); LineItem item = new LineItem(); item.setProductId(productId); item.setProduct(product); cartService.addLineItem(item); ModelAndView modelAndView = new ModelAndView(); modelAndView.addObject("lineItemList", cartService.getLineItemMap().values()); modelAndView.setViewName("cartops/list"); return modelAndView; } }} ** cartops/list.vmの追加 [#r4acda56] cartopsフォルダをsrc/main/webapp/WEB-INF/velocityに作成し、list.vmを追加します。 簡単な項目一覧をlist.vmで出力します。 #pre{{ <html> <head> <title>Cart</title> </head> <body> <h1>Your Wine Cart</h1> <ul> #foreach ($lineItem in $lineItemList) <li> $lineItem.quantity × $lineItem.product.title </li> #end </ul> </body> </html> }} ** コンフィグファイルへのCartOpsControllerの追加 [#t701a745] CartOpsControllerは、GenMVCプラグインの影響を受けないようにserver-def.xmlに定義します。 #pre{{ <bean id="cartOpsController" class="example.cart.web.CartOpsController" parent="baseProductController"/> }} ブラウザーからカタログ画面を表示(http://localhost:8080/cart/productops/catalog.htm)し、Add to Cartボタンを押すと以下のような画面に遷移します。 #ref(cart1.jpg); ** 価格とEmpty cartボタンの追加 [#kcab59f9] 次に小計と合計の表示とEmpty cartボタンを追加し、ひとまずcartの完成としましょう。 - list, addにtotalを追加登録します #pre{{ modelAndView.addObject("total", cartService.getTotal()); }} - emptyCartを追加します #pre{{ public ModelAndView emptyCart(HttpServletRequest request, HttpServletResponse response) throws Exception { setupCartService(request); cartService.getLineItemMap().clear(); ModelAndView modelAndView = new ModelAndView(); modelAndView.setViewName("redirect:/productops/catalog.htm"); return modelAndView; } }} 最終的な画面は、以下のようになります。 #ref(cart2.jpg); * チェックアウトの処理 [#y471a9d2] 最後にチェックアウトの処理を追加します。 ** Orderの作成 [#a8ebd5e7] 注文書には、以下の項目を入れます。 - name 購入者の氏名 - address 購入者の住所 - email 購入者のe-mailアドレス - payType 支払い方法 domainクラスとしてOrderを作成します。 #pre{{ package example.cart.domain; import java.util.List; public class Order { private Integer id; private String name; private String address; private String email; private String paytype; private List lineItemList; } }} を入力して、getter/setterを自動生成します。 そして、GenMVC:scaffoldを実行します。 #pre{{ mvn package mvn GenMVC:scaffold }} ** テーブルの関連づけ [#bf432f40] すべてのテーブルが出そろったので、テーブルの関連づけをします。 詳しくは、http://www.pwv.co.jp/take_public_html/Model1st/docs/Model1st.html#doc1_905を参照してください。 - LineItemから参照しているProductを結合する場合、LineItem.hbm.xmlに以下の定義を追加します。 #pre{{ <many-to-one name="product" column="productId" cascade="save-update" class="example.cart.domain.Product"/> }} - Orderと複数のLineItem結合する場合、Order.hbm.xmlに以下の定義を追加します。 #pre{{ <bag name="lineItemList" cascade="save-update" <key column="orderId" foreing-key="ID"/> <one-to-many class="example.cart.domain.LineItem"/> }} 最後に、src/main/webapp/WEB-INF/hbm-dir/Order.hbm.xml, LineItem-hbm.xmlのバックアップを取ってください。 ** checkoutの追加 [#f04ead94] OrderOpsControllerにcheckoutメソッドを追加します。 それと同時にcartService属性を追加して、session scopeのcartServiceから注文項目を取り出します。 #pre{{ private CartService cartService; private void setupCartService(HttpServletRequest request) { ApplicationContext co = WebApplicationContextUtils.getRequiredWebApplicationContext( request.getSession().getServletContext()); cartService = (CartService)co.getBean("cartService"); } }} #pre{{ public ModelAndView checkout(HttpServletRequest request, HttpServletResponse response) throws Exception { setupCartService(request); Order order = new Order(); order.setLineItemList(new ArrayList(cartService.getLineItemMap().values())); manager.saveOrUpdate(order); ModelAndView modelAndView = new ModelAndView(); modelAndView.setViewName("editOrder.htm"); modelAndView.addObject(order); return modelAndView; } }}