■■■■■■■■■■■■■■■■■■■■■■■■■■■■■
                      2008年02月17日

    楽しいJava講座 - 初心者から達人へのパスポート
                  vol.092

                                セルゲイ・ランダウ
 バックナンバー: http://www.flsi.co.jp/Java_text/
■■■■■■■■■■■■■■■■■■■■■■■■■■■■■


[このメールマガジンは、画面を最大化して見てください。]


========================================================
◆ 01.Tomcatのアプリケーション開発
========================================================


今回は、OrderProcessServletクラスとorder.jspのソース・コードの
説明を行った後、Eclipseを使ってTomcatのアプリケーションのエラー
の発生源を調べる方法について解説していきます。


では、まずOrderProcessServletクラスのソース・コードを説明して
おきましょう。
--------------------------------------------------------
package jp.co.flsi.lecture.webapp.servlet;

import java.io.IOException;
import java.util.Enumeration;

import javax.servlet.ServletException;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;

import jp.co.flsi.lecture.webapp.entity.Order;
import jp.co.flsi.lecture.webapp.entity.OrderItem;

public class OrderProcessServlet extends HttpServlet {
   public void doPost(HttpServletRequest req, HttpServletResponse res) throws IOException, ServletException {
      req.setCharacterEncoding("Shift_JIS");
      HttpSession session = req.getSession();
      Order anOrder = (Order)session.getAttribute("ORDER");
      Enumeration paramNames = req.getParameterNames();
      while(paramNames.hasMoreElements()) {
         String aParamName = (String)paramNames.nextElement();
         if (!aParamName.equals("putIntoOrder")) {
            OrderItem anOrderItem = new OrderItem();
            for (OrderItem oi : anOrder.getOrderItems()) {
               if(aParamName.substring(0, 5).equals(oi.getNum())) {
                  anOrderItem.setNum(oi.getNum());
                  anOrderItem.setName(oi.getName());
                  anOrderItem.setPrice(oi.getPrice());
                  anOrderItem.setImage(oi.getImage());
                  anOrderItem.setCategory(oi.getCategory());
                  anOrderItem.setOrderQuantity(Integer.parseInt((String)req.getParameter(aParamName)));
               }
            }
            anOrder.removeOrderItem(anOrderItem);
            if (anOrderItem.getOrderQuantity() > 0) {
               anOrder.addOrderItem(anOrderItem);
            }
         }
      }
      session.setAttribute("ORDER",anOrder);
      getServletContext().getRequestDispatcher("/order.jsp").forward(req,res);
   }


}
--------------------------------------------------------

このサーブレットは、すでにショッピング・カートにはいっている商品を
注文(購入)するための手続きを行うためのものです。

このサーブレットはcart.jspのWebページから要求を受けて起動されます。
cart.jspのWebページでは、ショッピング・カートにはいっている商品の
購入個数(注文数)を変更できますので、このサーブレットではWebページ
から受け取った購入個数を調べ、そのデータでショッピング・カートの中の
注文数のデータを置き換えます。

このサーブレットのソース・コードはほとんど自力で理解できることと
思いますが、少しだけ解説しておきましょう。

まず、cart.jspに
<INPUT TYPE="text" NAME="<%= anOrderItem.getNum() + "_quantity" %>" VALUE="<%= anOrderItem.getOrderQuantity() %>" SIZE=1>
という行があるのを確認してください。
この行によって、商品の購入個数を表示し、購入個数は変更できるよう
になっています。そして、この購入個数のデータは
anOrderItem.getNum() + "_quantity"
という文字列のパラメーター名でサーブレットに渡されます。
つまり、

商品番号+"_quantity"

というパラメーター名です。

商品番号をパラメーター名にすることは、注文される商品が複数あるときに
各商品をユニークに識別するために有効な方法です。しかし、商品番号だけ
だと、同じ商品に対しては1つのデータしか識別できません。
そこで、もし同じ商品に対して複数の列のデータをサーブレットに伝えたい
場合には、上記のように商品番号の後ろに何らかの(列を区別するための)
文字列を付けたして、他の列と区別できるようにします。

今回はテクニックとして紹介するためにあえてこのコードを組み込んだだけ
であり、他に識別しなければならない列のデータはありません。
というわけで、このパラメーター名を読み取るOrderProcessServletサーブ
レット側のコードは、

if(aParamName.substring(0, 5).equals(oi.getNum())) {

というように、パラメーター名から商品番号の部分を取り出してチェックする
だけにして、後ろの"_quantity"の部分はチェックしていません。
もし、他の列のデータと区別するために後ろの文字列までチェックしたい
場合は、代わりに

if(aParamName.substring(0, 5).equals(oi.getNum())
   && aParamName.substring(5, 14).equals("_quantity")) {

というふうなコードにすることになります。


さて、ではWebページから受け取った購入個数でどうやってショッピング・
カートの中の注文数のデータを置き換えているかというと、
さらにその6行下にある

anOrderItem.setOrderQuantity(Integer.parseInt((String)req.getParameter(aParamName)));

というコードが、ひとまずパラメーターの値(購入個数)を取り出して
OrderItemの新しい注文数として設定し直しています。

そして、さらにその下にある

anOrder.removeOrderItem(anOrderItem);
if (anOrderItem.getOrderQuantity() > 0) {
   anOrder.addOrderItem(anOrderItem);
}

というコードが、該当する商品(anOrderItem)をショッピング・カートのオブ
ジェクトからいったん削除してから、新しく設定しなおした商品(anOrderItem)
をショッピング・カートのオブジェクトに追加し直しています。これで、
置き換えが完了します。
このとき、購入個数が0のものは、ショッピング・カートのオブジェクトから
削除されたままにし、追加し直されないことに注意してください。



では続いて、order.jspのソース・コードの説明をしておきましょう。
--------------------------------------------------------
<%@ page language="java" contentType="text/html; charset=UTF-8" pageEncoding="Shift_JIS" %>

<HTML>
<HEAD>
<TITLE>購入手続き</TITLE>
</HEAD>
<BODY bgcolor="#77ffff" text="#fa5a00">
<H1>購入する商品</H1>
<FORM action="confirmorder" method="POST">
<TABLE border="1" width="100%">
  <TBODY>
    <TR>
      <TH>商品番号</TH>
      <TH>商品名</TH>
      <TH>価格</TH>
      <TH>カテゴリー</TH>
      <TH>購入個数</TH>
    </TR>

<%@ page import="jp.co.flsi.lecture.webapp.entity.*" %>
<% int totalPrice = 0; %>
<jsp:useBean class="jp.co.flsi.lecture.webapp.entity.Order" id="ORDER" scope="session" />
<%    OrderItem anOrderItem;
      for (int i = ORDER.getOrderItems().size() - 1; i >= 0 ; i--) {
         anOrderItem = ORDER.getOrderItems().elementAt(i); %>
         <TR>
            <TD><%= anOrderItem.getNum() %></TD>
            <TD><%= anOrderItem.getName() %></TD>
            <TD><%= anOrderItem.getPrice() %>円</TD>
            <TD><%= anOrderItem.getCategory() %></TD>
            <TD align="right"><%= anOrderItem.getOrderQuantity() %></TD>
          </TR>
<%         totalPrice += anOrderItem.getPrice() * anOrderItem.getOrderQuantity();
      } %>

  </TBODY>
</TABLE>
<BR>
<TABLE border="1">
    <TR>
      <TH>合計金額:</TH>
      <TH><%= totalPrice %>円</TH>
    </TR>
</TABLE>
<BR>
<BR>
ユーザーIDとパスワードをご入力ください。
<TABLE border="0">
    <TR>
      <TH>ユーザーID:</TH>
      <TD><INPUT TYPE="text" NAME="userId" VALUE="" SIZE=20></TD>
    </TR>
    <TR>
      <TH>パスワード:</TH>
      <TD><INPUT TYPE="password" NAME="password" VALUE="" SIZE=20></TD>
    </TR>
</TABLE>
<BR>
<BR>
<INPUT TYPE="submit" NAME="confirmOrder" VALUE="最終購入確認画面に進む">
</FORM>
</BODY>
</HTML>
--------------------------------------------------------

これもほとんどの部分がいつものパターンですね。

いつもと違うところだけ簡単に説明しておきましょう。

まず、
<%         totalPrice += anOrderItem.getPrice() * anOrderItem.getOrderQuantity();
      } %>
の行では合計金額の計算を行っています。
これはforループの中で注文する商品を一つずつ取り出しながら、
その価格と注文数の積の値を足していっています。

そして、その最終的な結果は

<TH>合計金額:</TH>
<TH><%= totalPrice %>円</TH>

という行で出力しています。


その後、ユーザーIDとパスワードの入力を要求していますが、

<TD><INPUT TYPE="password" NAME="password" VALUE="" SIZE=20></TD>

の行のようにINPUTのTYPE属性の値を"password"にしておくと、入力する文字を
*****のように表示して隠してくれます。


残りのソース・コードの部分は、自力で理解できることと思います。



それでは、続いて、Eclipseを使ってTomcatのアプリケーションのエラー
の発生源を調べる方法について解説しましょう。


では、これから実際にエラーを発生させて、その発生源の調べ方
および対処方法を説明していきます。

まずEclipseでTomcatを起動し、Webブラウザーを起動して、
下記のURLを入力してください。

http://localhost:8080/JStudy2/itemSelect.html

(1) 「キーワード」欄、「カテゴリー」欄ともに何も入力せずに
「送信」ボタンをクリックしてみてください。

(2) 次のページ(商品リストのページ)で商品を一つ選択(チェッ
ク・マークを入れる)し、「選択した商品をカートに入れる」ボタン
をクリックします。

(3) 選択した商品が次のページ(ショッピング・カートのページ)で
リストされていることを確認したあと、その商品の購入個数を0に
して「選択した商品を購入する」ボタンをクリックしてください。

(4) 次のページ(購入する商品のページ)でリストが空っぽになって
いることを確認し、その後、EclipseでTomcatを再起動してください。
(メニュー・バーの「Tomcat」→「Tomcat再起動」を選択する。
あるいはツール・バーにも「Tomcat再起動」のアイコンがあるので
それをクリックしてもよい。なお、いったんTomcatを停止して
から再度Tomcatを起動しても同じことである。)

(5) Webブラウザーの「戻る」ボタンをクリックして前のページに
戻ってください。

(6) ショッピング・カートのページに戻ったら、商品の購入個数が0の
状態のままで、「選択した商品を購入する」ボタンをクリックして
ください。

そうすると、エラー(例外)を報告するページになるはずです。

エラーの内容として、次のような例外(Exception)が表示されて
いますね。

java.lang.NullPointerException
   jp.co.flsi.lecture.webapp.servlet.OrderProcessServlet.doPost(OrderProcessServlet.java:25)
   javax.servlet.http.HttpServlet.service(HttpServlet.java:710)
   javax.servlet.http.HttpServlet.service(HttpServlet.java:803)

このNullPointerExceptionというのは、「オブジェクトにアクセスしようと
したが、nullだったからアクセスできない」という例外です。
オブジェクトを代入しているはずの変数が空っぽ(値がnull)だったときに、
その変数を使ってオブジェクトにアクセスしようとすると発生します。

こういった例外の(より詳細な)報告は、tomcatPluginによって、Eclipseの
コンソールに書かれています。(Tomcatのログ出力が、tomcatPluginによって
Eclipseのコンソールに書き出されるのです。)

Eclipseのウインドウの下のほうにある「コンソール」(「コンソール」タブ
下の領域)の中を探してください。同じように

java.lang.NullPointerException
   at jp.co.flsi.lecture.webapp.servlet.OrderProcessServlet.doPost(OrderProcessServlet.java:25)
   at javax.servlet.http.HttpServlet.service(HttpServlet.java:710)
   at javax.servlet.http.HttpServlet.service(HttpServlet.java:803)
         (以下省略)

というところがありますね。
そこの
java.lang.NullPointerException
   at jp.co.flsi.lecture.webapp.servlet.OrderProcessServlet.doPost(OrderProcessServlet.java:25)
というのは、OrderProcessServletクラスのdoPost()メソッドの中で
NullPointerExceptionが投げられている(発生している)ことを意味し、
(OrderProcessServlet.java:25)という表記によって、その発生箇所が
OrderProcessServlet.javaのソース・コードの25行目であることを意味
しています。
その
OrderProcessServlet.java:25
のところにアンダーラインが引かれていますね。これは、ソース・コードの
該当箇所にリンクしていることを意味していますので、そのアンダーライン
の引かれている文字列をクリックしてみてください。
ソース・コードの該当箇所がハイライトされ(色が反転し)て表示されます
ね。

for (OrderItem oi : anOrder.getOrderItems()) {

というコードでNullPointerExceptionが発生していたことがわかります。


このanOrderは何だったかというと19行目の

Order anOrder = (Order)session.getAttribute("ORDER");

という行によってセッション・オブジェクトからショッピング・カートの
オブジェクトをanOrderに代入したはずなのですね。

ところが、今回はその前にTomcatを再起動していました。

Tomcatを再起動すると、まっさらの状態になりますから、その前に保存
されていたセッション・オブジェクトは失われてしまいます。
したがって、そのセッション・オブジェクトに保存していたショッピング・
カートのオブジェクトも消えてしまっているわけです。

したがって、このanOrderにはショッピング・カートのオブジェクトは代入
されず、空っぽ(null)になっているはずです。

そこで、25行目のanOrder.getOrderItems()でショッピング・カートのオブジェクト
のgetOrderItems()メソッドを呼び出そうとしたときにNullPointerExceptionが
発生したのです。


実は、変数にオブジェクトを代入するような文を書いたときには、その下で変数
の値がnullでないかどうかをチェックし、nullの場合の対処法をコーディング
しておくのがプログラミング上の鉄則です。

というわけで、あとでその対処方法を説明します。



ところで、このエラーはとても簡単なものだったので、容易に原因がわかる
のですが、一般にはもっとわかりにくいエラーが発生することが多いのです。

その場合にエラーの分析に威力を発揮するのが、デバッガー(debugger)
です。
デバッガーというのは、デバッグ(debug)をするときに使うソフトウエア
のことで、デバッグ(debug)とは、プログラムの誤りを見つけて修正する
ことを意味します。

┌補足─────────────────────────┐
debugの元々の意味は、虫を取り除くことです。
(bugの元々の意味は「虫」で、deは「取り除く」という意味を
持つ接頭辞です。)
では、どうしてdebugがプログラムの誤りを見つけて修正する
意味になったかというと、それはコンピューターがまだパンチ・
カード(紙のカードに穿孔したもの)を使っていた時代に遡り
ます。
当時は、プログラムをパンチ・カードで作成し、カード・リーダー
(パンチ・カードに書かれた情報を読み取ってコンピューターに
入力する装置)に読ませて使っていました。
あるときプログラマーは、自分が作成したプログラムが意図通りに
動作しないので、何度もカードを調べ直しましたが、どこにも誤り
は見つかりません。
そこで念のためにカード・リーダーのカード差込口の中を覗きこ
んで調べて見ると、虫の死骸がつまっていました。
そして、虫を取り除くとプログラムが正常に動作するようにな
りました。
このことから、プログラムの誤りのことをバグ(bug = 虫)と
呼び、プログラムの誤りを取り除くことをデバッグ(debug)と
呼ぶようになったのです。
└───────────────────────────┘


tomcatPluginを使ってTomcatをEclipseで起動することの一番のメリット
は、Eclipseのデバッガーが使えることなのです。

では、デバッガーを使って調べる方法を少しお話しておきましょう。
なお、詳しいデバッガーの使い方は、また別の機会にお話しますので、
今回は、イメージをつかむ程度に理解していただくだけで結構です。


では、Eclipseのエディターで、さきほどのOrderProcessServletの
ソース・コードの25行目

   for (OrderItem oi : anOrder.getOrderItems()) {

の行の左側を見てください。行番号の数字のさらに左側に灰色の縦の帯状の
部分がありますね。そこをダブル・クリックしてください。
丸い水色のマークが付きますね。これはブレーク・ポイント(break point)
と言って、プログラムの実行時にこのマークが付いた行に来るとプログラム
をいったん停止してくれます。


では、いったんTomcatを再起動してから、上記の(1)〜(6)までを再度実施して
みましょう。そうすると(3)を実施した時点でEclipseに
「パースペクティブ切り替えの確認」というウインドウが現れますので、
「はい」ボタンをクリックしましょう。
Eclipseの画面がガラッと変わりますが、これがデバッガーの画面(デバッグの
パースペクティブ)です。

この段階では、さきほどのソース・コードの25行目
   for (OrderItem oi : anOrder.getOrderItems()) {
の行で停止しています。(行の色が変わっていて、停止している位置がすぐ
わかるようになっています。)
この段階ではanOrderはnullではなくオブジェクトがはいっているはずです。
このanOrderの上にマウス・ポインターをかざすと
anOrder= Order (ID=nn)
(ただしnnは数字です。)と表示されますね。
これは、anOrderにOrder型のオブジェクトが代入されていることを意味します。
つまり、nullではないことがわかります。

さらに詳しくanOrderの中身を調べたいときは、anOrderをダブル・クリックして
から右クリックし、「インスペクション」を選択してみてください。
中にはいっているオブジェクトの中のフィールドの値も調べられます。
なお、右上のほうにある「変数」タブのところに変数のリストがありますが、
そのリストの中でanOrderを探すと、同じ内容がリストされていますので、
そちらで調べることもできます。

では、プログラムの実行を進めましょう。
Eclipseのウインドウの上のほうの「デバッグ」タブの
右側に緑色の三角形のボタンがあります(赤色の四角形のボタンの左側にあります)
ので、これをクリックしてください。これは再開のボタンなので、停止していた位置
から再度実行されますが、ループの中の行だったので、ループを繰り返してすぐ
また同じブレーク・ポイントの位置で停止します。
同じく緑色の三角形のボタンを何度かクリックすると、そのうちにループを抜け出し、
緑色の三角形のボタンは薄くなってクリックできない状態になります。
この時点では、Webブラウザーに結果のページが返ってきていますので、(4)以降を
実施していきましょう。
そうすると(6)を実施した時点で再度さきほどの
   for (OrderItem oi : anOrder.getOrderItems()) {
の行で停止しますので、Eclipseのデバッガーの画面の中でOrderProcessServlet.java
のソース・コードを見てください。
   for (OrderItem oi : anOrder.getOrderItems()) {
の行の色が変わっていますね。(ここで停止しているという意味です。)
そこで、先ほどと同じようにanOrderという変数にマウス・ポインターをかざすと
anOrder= null
というのが表示され、anOrder変数の値がnullであることがわかります。
あるいは、anOrderという変数をダブル・クリックしてからその場で右クリック
し、「インスペクション」を選択してください。
"anOrder"=null
というのが表示され、anOrder変数の値がnullであることがわかります。
この場合、オブジェクトがはいっていないわけだから、オブジェクトの中の
フィールドなどは表示されません。
(あるいは、右上のほうにある「変数」タブのところに変数のリストがありますが、
そのリストの中でanOrderを探すと、そこにも値がnullであることが示されて
いるのがわかります。)

この状態でさらに実行を続けると、(オブジェクトがはいっているべき変数が
nullなので)NullPointerExceptionが発生することになります。


では、ブレイク・ポイントのマークはいったんダブル・クリックして取り除き、
パースペクティブをJavaに切り替えておきましょう。
(パースペクティブの開き方を忘れた人は、vol.083あたりを復習してください。)

Tomcatもいったん停止しておいてください。

┌補足─────────────────────────┐
今回はデバッガーの再開のボタンしか使いませんでしたが、通常
のデバッガーの操作では、「ステップイン」「ステップオーバー」
「ステップアウト(Eclipseではステップ・リターンと呼んでいる)」
といった機能を使ってプログラムの実行を少しずつ進めながら
調査を進めていきます。そして、変数の値の変化を調べたり、
変数を試験的に別の値に変えたりしながら、プログラムの動き
を詳しく調べていきます。
これらの操作方法は別の機会に詳しく説明いたします。
└───────────────────────────┘


今回のエラーはTomcatの再起動という、通常の運用では頻度の少ない
事態で生じましたが、他にも、ある決まったパターンで生じるエラーは
たくさんあり、その対処方法はWebアプリケーションにおける常套手段
になっています。
これらの対処方法は、のちほどまとめて説明いたします。



さて、先ほどのNullPointerExceptionの対処方法ですが、
以下のようにOrderProcessServletのソース・コードを変更
しましょう。
--------------------------------------------------------
package jp.co.flsi.lecture.webapp.servlet;

import java.io.IOException;
import java.util.Enumeration;

import javax.servlet.ServletException;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;

import jp.co.flsi.lecture.webapp.entity.Order;
import jp.co.flsi.lecture.webapp.entity.OrderItem;

public class OrderProcessServlet extends HttpServlet {
   public void doPost(HttpServletRequest req, HttpServletResponse res) throws IOException, ServletException {
      req.setCharacterEncoding("Shift_JIS");
      HttpSession session = req.getSession();
      Order anOrder = (Order)session.getAttribute("ORDER");
      if (anOrder == null) {
         getServletContext().getRequestDispatcher("/cartmissing.html").forward(req,res);
      }
      else {
         Enumeration paramNames = req.getParameterNames();
         while(paramNames.hasMoreElements()) {
            String aParamName = (String)paramNames.nextElement();
            if (!aParamName.equals("putIntoOrder")) {
               OrderItem anOrderItem = new OrderItem();
               for (OrderItem oi : anOrder.getOrderItems()) {
                  if(aParamName.substring(0, 5).equals(oi.getNum())) {
                     anOrderItem.setNum(oi.getNum());
                     anOrderItem.setName(oi.getName());
                     anOrderItem.setPrice(oi.getPrice());
                     anOrderItem.setImage(oi.getImage());
                     anOrderItem.setCategory(oi.getCategory());
                     anOrderItem.setOrderQuantity(Integer.parseInt((String)req.getParameter(aParamName)));
                  }
               }
               anOrder.removeOrderItem(anOrderItem);
               if (anOrderItem.getOrderQuantity() > 0) {
                  anOrder.addOrderItem(anOrderItem);
               }
            }
         }
         session.setAttribute("ORDER",anOrder);
         getServletContext().getRequestDispatcher("/order.jsp").forward(req,res);
      }
   }

}
--------------------------------------------------------

何が変更されたか、わかりますね。

Order anOrder = (Order)session.getAttribute("ORDER");

の行の下に、anOrderの値がnullの場合の処理が加えられました。
このときは、JSPの代わりにcartmissing.htmlというHTMLファイルを呼び出す
ようにしています。
なお、従来の処理はanOrderの値がnullでないときのものなので、elseの
ブロックに囲みましたね。

ここでcartmissing.htmlというのは、エラーの内容をユーザーに示すための
ものです。というわけで、JStudy2直下(itemSelect.htmlなどが置かれている
ところ)に下記の内容のcartmissing.htmlファイルを作成しておいて
ください。
--------------------------------------------------------
<HTML>
<HEAD>
<TITLE>システム障害</TITLE>
</HEAD>
<BODY bgcolor="#77ffff" text="#fa5a00">
<H1>システム障害</H1>
システム障害によりショッピング・カートが消失しました。
<BR>
まことに申し訳ありませんが、商品の検索から再度やり直してください。
<BR>
<BR>
<A href="http://localhost:8080/JStudy2/itemSelect.html">
商品の検索に戻るには、ここをクリック
</A>
<BR>
</BODY>
</HTML>
--------------------------------------------------------


では、再度さきほどの(1)〜(6)までを実施してテストしてみてください。
こんどは、NullPointerExceptionの画面は出ませんね。代わりにわかり
やすいメッセージが出て、商品の検索の画面に誘導するようになりまし
たね。


(次回に続く)


では、今日はここまでにします。

何か、わからないところがありましたら、下記のWebページまで質問を
お寄せください。



┏━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
★ホームページ:
      http://www.flsi.co.jp/Java_text/
★このメールマガジンは
     「まぐまぐ(http://www.mag2.com)」
 を利用して発行しています。
★バックナンバーは
      http://www.flsi.co.jp/Java_text/
 にあります。
★このメールマガジンの登録/解除は下記Webページでできます。
      http://www.mag2.com/m/0000193915.html
★このメールマガジンへの質問は下記Webページにて受け付けて
 います。わからない所がありましたら、どしどしと質問をお寄
 せください。
      http://www.flsi.co.jp/Java_text/
┗━━━━━━━━━━━━━━━━━━━━━━━━━━━┛

Copyright (C) 2008 Future Lifestyle Inc. 不許無断複製