jQuery, JSON, SQLite3, JavaServlet

ちょっと Web サイトの見直しのために,データベース検索の日曜大工をしているところである。その過程で,最近流行の jQuery JavaScript ライブラリで遊んだりもしている。jQuery は動的な Web サイトを構築するに際していまやなくてはならないプラットフォームになっていて,これを使えば少ないコードで簡便に,コンテンツを書換えたり,アニメーションを付加したりできる。jQuery はバージョンアップで古いコードが動かなくなることがあるのが悩ましいのだが。

データベース検索というと,キーワード等の検索条件に合致する検索結果を表示するにあたり,一定の件数を表示するとともに,前後ページ,あるいは複数ページへのリンクを示してナビゲートするのが一般的インタフェースといえる(ネクストページ・インタフェースというそうである)。私の今回の日曜大工の課題は,自分なりにこの仕組みを構築してみることだった。JavaScript でクエリをサーバに送信し,Java Servlet でデータベースを検索し,その応答を再度 JavaScript で整形して HTML として表示する。ここで JavaScript と Java Servlet のデータインタフェースを JSON(JavaScript Object Notation)データ記述言語に準拠することにした。

大規模システムではシステム間通信のデータ記述言語として XML を選択することが多いと思われる。これは,よく言われるとおり,XML は「自己説明的」(誰もがこれを口にするが,何がメリットなのかよくわからない。ま,要するに,見て意味がわかりやすいということ)で,大人数を擁するプロジェクトで共有する基盤として適当であるし,また SAX や Xerces などの便利な XML パーサーがたくさん転がっているためである。一方,JSON はその名のとおり(十三日の金曜日ではなく)JavaScript で操作するに都合のよいデータ構造のため,Web 2.0 以降大ブレークした。つまり JavaScript 言語の配列定義とまったく同じ書き方なので,データを eval(外部データを JavaScript コードとして評価する命令)に食わせると,即 JavaScript の配列として扱える(もちろん,外部データをそのまま JavaScript コードとして実行してしまうと何が起るかわからないリスクがある)。今回の課題は JavaScript に労力が集中するので,JSON で行くことに。個人の日曜大工でしかも簡易に作ることを考えると,XML だの,JSON だのを持ち出さなくても,データを CSV 形式で受け渡すのがもっともラクだと思われるのであるが,ま,勉強。

jQuery には ajax メソッドが用意されている。つまり,かつて XMLHttpRequest なり ActiveXObject なりの低レベルの(「おつむが足りない」ということではなくて,よりコンピュータの作りに近い,従って面倒な)オブジェクトを直接操作していたのに比べると,ブラウザの種類に悩まされず比較的簡便に,サーバとの非同期通信コードを書くことができるようになった。今回,これを使わない手はない。

前置きが長くなるのがボクの悪いクセ(アホか)。ある著名な俳句・短歌専門出版社の書籍データを用いて書籍検索システム(ネクストページ・インタフェース)を構築してみた。サーバ側は,Java Servlet on Tomcat, FreeBSD 8。データベース・マネージャは SQLite3 である。ちょっと使ってみていただきたい(そのうち撤去するかも知れないが)。注文カートに入れるボタンも付けてある。もちろん架空のインターネットショッピング・サイトのイメージである。Cookie を食わせるので,注意いただきたい。

2015/2/17 書籍検索サンプルサーブレットは停止しました。 books-wapp-1.png

もし上のフレームでうまく動かないようなら(ドメイン絡みで Cookie がうまく食わせられない),http://yasuda.homeip.net/books/ にアクセスしてみてください。

2015/2/17 書籍検索サンプルサーブレットは停止しました。

さて,上記検索プログラムの構築について,簡単にメモを残しておく。前置きが長くなるのがボクの悪いクセ(アホか)。

ネクストページ実現方式

ネクストページの実現は,一発目の検索で一ページに表示すべき明細とともにヒット件数を取得し,ページ当りの表示件数とそこから計算できるオフセット数値をもとに,前後ページのリンクを設置して行けばよい。つまり,仮にページ当りの表示件数が 10 件で,検索ヒット件数が 32 件だとすると,現在ページが 1 ページなら 2 ページ目のリンクは 11 件目から 10 件を,4 ページ目は 31 件目から 2 件を表示すべきリンクとなる。このリンクにデータベース検索における limit 10 offset N (N は 開始のオフセット) クエリを埋め込むようにすればよいわけである。検索種別(一発目の件数取得をする: 1 か,否: 0 か),オフセット(言わずもがなであるが,offset クエリは 0 から始まるので,21 件目のオフセットは 20 である)を引数に取ってサーバ・データベースにクエリを出す JavaScript 関数(bookSearch(kind, offset))を埋め込むのである。たとえば,次のように。

<a class="pglink" href="javascript:void(0)" onClick="bookSearch(0, 10);">2</a>
3 (現在ページ)
<a class="pglink" href="javascript:void(0)" onClick="bookSearch(0, 30);">4</a>
...

bookSearch(0, 30) を受けて,キーワード「短歌」で 31 件目から 6 件分を取って来る SQL は次のようになる。 textdt に書籍のテキストデータをまとめて放り込んであり,「短歌」の含まれるレコードを like 文で探索する。

select * from BOOKTBL where textdt like '%短歌%' limit 6 offset 30;

DB 検索 Servlet Java コード

サーバ側 JavaServlet のコードを示しておく。これは完全なコードで,このままでコンパイルが通るはずである。一回目の検索だけ(kind パラメータが 1 のとき)select count(*) でヒット件数を取得するようになっている。JSON データの構築に JsonGenerator クラス(javax.json-1.0.4.jar)を利用している。javax.json クラスライブラリは JSON データオブジェクトを DOM,Stream の両方で支援するが,今回のように DB 項目をベタに返すシンプルなパターンでは,Stream に書き出すので充分である。ここでは StringWriter 上に検索結果を JSON データとして整形している。

// -*- coding: utf-8; mode: java; -*-
import java.io.*;
import java.net.*;
import java.util.regex.*;
import javax.servlet.*;
import javax.servlet.http.*;
import java.sql.*;
import javax.json.*;
import javax.json.stream.*;
import org.apache.log4j.Logger; 
/**
 * <pre>
 *  BookSearch 書籍検索 JSONデータインタフェース
 *  Copyright(c) 2013, isao yasuda, All Rights Reserved.
 * </pre>
 * @author 安田  功 (Isao YASUDA)
 * @version $Id: insomnia.txt 222 2014-03-27 11:23:12Z isao $
 */
 
public class BookSearch extends HttpServlet 
{
    /** SQLite3 DB path */
    private static String dbpath = "/usr/local/etc/BOOK.db";
    /** SQL */
    private String query = null;
    /** 許容ドメイン */
    private static String authsite = "yasuda.homeip.net";
    /** ドメインチェック用正規表現パターン */
    private static Pattern dmptn;
    /** デバッグモード */
    private static Boolean debug = false;
    /** Log4j Logger */
    private static Logger log = Logger.getLogger(BookSearch.class.getName());
 
    /** 初期化 */
    public void init() throws ServletException 
    {   
        // デプロイメントディスクリプタから初期パラメータを取得する.
        String p = null;
        p = getInitParameter("dbpath");
        if (p != null) dbpath = p;
        p = getInitParameter("debug");
        if (p != null)
            if (p.equals("true")) debug = true;
        p = getInitParameter("authsite");
        if (p != null) authsite = p;
        dmptn = Pattern.compile(authsite);
 
        // 開始メッセージ (log4j)
        log.info("\nBook Search initialization." +
            "\n- SQLite3 Book Database file:: " + dbpath +
            "\n- Search authorized site: " + authsite +
            "\n- Debug mode: " + debug);
    }
 
    /** リクエスト処理 */
    public void doPost(HttpServletRequest req, HttpServletResponse res)
        throws ServletException, IOException
    {
        /** ブラウザ情報の取得 */
        String ip = req.getRemoteAddr();          // IP アドレス
        String ua = req.getHeader("User-Agent");  // User Agent
        String rf = req.getHeader("Referer");     // Referer
        log.info("Client: " + ip + ", " + ua + ", " + rf);
 
        /** リファラドメインチェック */
        Matcher dm = dmptn.matcher(rf);
        if (dm.find()) {
            if (debug) log.info(ip + ", " + rf + " authorized.");
        } else {
            log.info(ip + ", " + rf + " not authorized.");
            return; // 許可されていないページからの要求は無視
        }
 
        /** DB検索リクエストパラメータ */
        String cond  = (String) req.getParameter("query");
        String qkind = (String) req.getParameter("kind");
        if (debug)
            log.info(ip + ", " + ua + ", " + rf + 
                " query: " + cond + "; kind: " + qkind + ";");
 
        /** DB検索用変数 */
        Connection conn = null;
        Statement  stmt = null;
        String query = null;
        String count = null;
 
        /** JSON */
        StringWriter wrtr = new StringWriter();
        JsonGenerator generator = Json.createGenerator(wrtr);
       
        try {
            // SQLite3 JDBC コネクション
            Class.forName("org.sqlite.JDBC");
            conn = DriverManager.getConnection("jdbc:sqlite:" + dbpath);
            conn.setAutoCommit(false);
            stmt = conn.createStatement();
            stmt.setQueryTimeout(10); // set timeout to 10 sec.
 
            // 検索結果セット
            ResultSet rset = null;
            // JSON Array
            generator.writeStartArray();       // JSON Array 開始 '['
            // 1回目のみ結果件数を取得
            if (qkind.equals("1")) {
                query = "select count(*) as cnt from BOOKTBL where "
                    + cond + ";";
                rset = stmt.executeQuery(query);
                count = rset.getString("cnt"); // ヒット件数
                generator.writeStartObject().  // count JSONオブジェクト '{'
                    write("count", count).     // count: ヒット件数
                    writeEnd();                // count 終了 '}'
            }
            // 検索実行
            query = "select * from BOOKTBL where " + cond + ";";
            rset = stmt.executeQuery(query);
            while (rset.next()) {
                // ResultSetをJSONオブジェクトにセット
                generator.writeStartObject(). // DB明細JSONオブジェクト開始 '{'
                    write("kind",    rset.getString("kind")).   // カテゴリー
                    write("image",   rset.getString("image")).  // カバー画像
                    write("price",   rset.getString("price")).  // 価格
                    write("pinfo",   rset.getString("pinfo")).  // 新刊等情報
                    write("title",   rset.getString("title")).  // 題名
                    write("subttl",  rset.getString("subttl")). // 副題
                    write("author",  rset.getString("author")). // 著者名
                    write("pubdate", rset.getString("pubdate")).// 刊行日
                    write("isbn",    rset.getString("isbn")).   // ISBN
                    write("comment", rset.getString("comment")).// コメント
                    writeEnd();   // 明細1件終了 '}'
            }
            generator.writeEnd(); // JSON Array を閉じる ']'
            generator.close();    // JSON Generator を閉じる
            rset.close();
            stmt.close();
            conn.close();
        }
        catch (Exception e) {
            log.error("BookSearch Error: " + e.getMessage());
        }
 
        /** 結果出力 */
        res.setContentType("application/json; charset=UTF-8");
        res.setHeader("Cache-Control", "no-cache");
        res.getWriter().write(wrtr.toString());
        log.info(ip + ", " + ua + ", " + rf + ", "
                 + "\nhits: " + count + ", Input: " + cond);
    }
 
    /** 終了 */
    public void destroy() 
    {
        log.info("Book Search Terminate.");
    }
}

SQLite3 データベースは,某俳句・短歌出版社のサイトから Web ページをダウンロードし,Perl HTML::Parser モジュールで,書籍タイトル,著者名,価格,カバー画像ファイル名,紹介文等々を抽出し,インポートすることで作成した。人間の書いたページは HTML コードに統制がなく,タグ抜けなどの誤りもあり,こういう機械処理では大いに苦労するものである。Amazon Web Service を使えば,もっと規模の大きなものを,もっと簡単に作成することができるだろう。

クライアントに返すデータを [ { "項目1": "val11", "項目2": "val12", ... }, { "項目1": "val21", "項目2": "val22", ... }, ... ] というように JSON 配列様式に整形しているところがポイントである。setContentType にも application/json; を指定してある(UTF-8 の文字列形式であれば,この指定は必須ではない)。DB 検索種別(kind パラメータ)が 1 のとき,一発目の検索指示ということで,ヒット件数を配列先頭に付加して返す。

コンパイルには Java Servlet 関係のクラスライブラリのほか,JSONP (Java API for JSON Processing) JSR 353: 1.0.4(Java EE 標準に組込まれようとしている最中で,頻繁に仕様が変るのが悩ましい),Log4j 1.2,SQLite3-JDBC が必要である。

JavaScript コード

クライアント,すなわちブラウザで動作する JavaScript の主要コードは以下のとおりである。モーダルウィンドウ表示,オーダーなど,こまごました付随的な処理を記述した下請け関数は割愛してある(だから,このまま掲載コードをコピペしただけでは動きません)。メインは,ネクストページリンクとなる bookSearch 関数,SQL クエリを組立てる makeQuery 関数,サーバに非同期通信要求を出す invokeServer 関数,検索結果明細を表示する editResult 関数,ページリンクを組立てる makePageLink 関数である。invokeServer 関数のなかで,jQuery $.ajax を使い,さらにこれに対し JSON インタフェース(dataType: "json")を指示することにより,データを受けとった段階ですぐさま JavaScript 配列としてアクセスできるようにしている。jQuery ajax は非同期通信の処理(成功時,エラー時,後始末)を簡潔に書くことができる。jQuery を使うようになったいま,かつて getElementById なんかでちまちま HTML 要素を操作していたのが懐かしくなる。

// -*- coding: utf-8; mode: javascript; -*-
// BookSearch 書籍検索
// 2013(c) isao yasuda, All Rights Reserved.
// $Id: insomnia.txt 222 2014-03-27 11:23:12Z isao $
 
// Global Valuables
var skind  = 1;    // 検索種別: 最初の検索時またはヒット0のとき1
var sptext = null; // 検索結果表示先頭に挿入するsnippet
var squery = null; // SQL where句 parts
var scount = null; // hit 件数
var skword = null; // 検索キーワード
var sgenre = null; // 検索対象ジャンル
var ssitem = null; // 新刊等の属性項目
var sprmin = null; // 価格下限値
var sprmax = null; // 価格上限値
var slimit = 12;   // 表示件数デフォルト
var sofset = 0;    // オフセット
var bookid = 0;    // 書籍ID(注文用)
var explimit = 6;  // cookie expire hours
 
// サーバに検索リクエストを POST する 
function invokeServer() {
    // SQL where句のクエリーを整形する
    makeQuery();
    var kind = "0"; if (skind) kind = "1"; // 検索種別 "1": ヒット件数付き
    // Ajax Communication to receive JSON data
    $.ajax({
        url: "http://yasuda.homeip.net/books/search", // JavaServlet App URL
        type: "POST", 
        dataType: "json", 
        cache: false,
        data: { "query": squery + ' limit ' + slimit + ' offset ' + sofset, 
                "kind": kind }
    }).done(function(json, textStatus, jqXHR) {         // 通信成功
        editResult(json);
    }).error(function(jqXHR, textStatus, errorThrown) { // 通信失敗
        errorAlert("通信エラー", 
                   "status: " + textStatus + "\nthrown: " + errorThrown);
    }).complete(function(jqXHR, textStatus) {           // 通信完了
        window.status = "BookSearch completed, textStatus: " + textStatus;
    });
}
 
// Book Search
// - 指定検索種別とオフセット値で検索を実行する
function bookSearch(flag, offset) {
    sofset = offset; // offset 値設定
    skind  = flag;   // 検索種別設定
    if (skind) { // true なら新規検索
        var cflg = false; // 条件入力フラグ
        // 検索結果の前に挿入するテキスト
        sptext = '<h2>検索書籍</h2>';
        // 検索キーワード抽出
        var maxc = 100;  // キーワード長最大値(とりあえず)
        skword = $("#search-basic").val();
        if (skword.length > maxc) {
            errorAlert("検索条件エラー", "入力の文字数が" + 
                       maxc + "を越えています。それ以下にしてください");
            return;
        } else if (skword.match(/^\s+$/) || skword.length == 0) {
        } else  cflg = true;
        // 対象ジャンル
        sgenre = $("#genre").val();
        if (sgenre != 0) cflg = true;
        // 新刊
        ssitem = $("#sitem").prop("checked"); // 検索対象項目
        if (ssitem) {
            ssitem = 1;
            cflg = true;
        } else
            ssitem = null;
        // 価格範囲 check and set
        var num = $("#prmin").val(); // 価格下限
        if (num.match(/^[0-9]+/)) {
            sprmin = num;
            cflg = true;
        } else {
            if ((num != null) && (num.match(/[^0-9]+/))) {
                errorAlert("検索条件エラー", 
                         "価格下限値には数値(半角)以外指定できません");
                return;
            }
            sprmin = null; 
        }
        num = $("#prmax").val(); // 価格上限
        if (num.match(/^[0-9]+/)) {
            sprmax = num;
            cflg = true;
        } else {
            if ((num != null) && (num.match(/[^0-9]+/))) {
                errorAlert("検索条件エラー", 
                         "価格上限値には数値(半角)以外指定できません");
                return;
            }
            sprmax = null; 
        }
        // 検索実行要件チェック
        if (!cflg) {
            errorAlert("検索条件エラー", "検索実行にはキーワード,...");
            return;
        }
        // limit check and set
        num = $("#limit").val(); // 表示個数
        if (num.match(/^[0-9]+/)) {
            if ((num < 5) || (num > 50)) {
                errorAlert("表示個数範囲エラー", 
                         "表示個数は 5 以上 50 以下でなければなりません");
                return;
            }
            slimit = num - 0;
        } else {
            if ((num != null) && (num.match(/[^0-9]+/))) {
                errorAlert("条件設定エラー", 
                         "表示個数には数値(半角)以外指定できません");
                return;
            }
            slimit = 6; // default 
        }
    }
    // サーバ実行要求を出す
    invokeServer();
}
 
// SQL where句条件を組み立てる
function makeQuery() {
    squery = ""; // クエリ(where句以下)
    var andflg = false; // and で連結すべきかどうか true: する; false: しない
    // keywords
    if ((skword != "") && (skword != null)) {
        if (skword.match(/\s+/)) { // 空白文字区切りcheck
            var words = skword.split(/\s+/);
            var len = words.length - 1; // 最後の項目は null なので無視
            if (len > 0) { // キーワード複数の場合
                for (var i = 0; i < len; i++) {
                    if (words[i] != "") 
                        squery += 'textdt like \'%' + words[i] + '%\' and ';
                }
                if (words[len] == "")
                    squery = squery.replace(/ and $/, "");
                else
                    squery += 'textdt like \'%' + words[len] + '%\'' ;
            } else // キーワード1個
                squery = 'textdt like \'%' + words[len] + '%\'' ;
        } else
            squery = 'textdt like \'%' + skword + '%\'' ;
        andflg = true;
    }
    // price 下限値
    if (sprmin != null) {
        if (andflg) {
            squery += ' and price >= ' + sprmin;
        } else {
            squery += 'price >= ' + sprmin;
            andflg = true;
        }
    }
    // price 上限値
    if (sprmax != null) {
        if (andflg) {
            squery += ' and price <= ' + sprmax;
        } else {
            squery += 'price <= ' + sprmax;
            andflg = true;
        }
    }
    // genre: sgenre には DB kind に 1 加えた値が入っている
    if (sgenre != 0) {
        if (andflg) {
            squery += ' and kind=' + (sgenre - 1);
        } else {
            squery += 'kind=' + (sgenre - 1);
            andflg = true;
        }
    }
    // NEW 書籍
    if (ssitem == 1) {
        if (andflg) {
            squery += ' and pinfo like \'%NEW%\'';
        } else {
            squery += 'pinfo like \'%NEW%\'';
            andflg = true;
        }
    }
}
 
// 検索結果 JSON データ編集 
// - 回答電文を編集し HTML に出力する
function editResult(json) {
    var edit = ""; // 整形後のHTMLブックsnippet
    if (sptext != null) edit += sptext; // 先頭挿入snippet
 
    // 検索結果の取り出し
    for (var i = 0; i < json.length; i++) {
        if (json[i].count != undefined) { // ヒット件数レコードの場合
            // ヒット件数
            scount = parseInt(json[i].count);
            // ヒット件数表示
            if (i == 0) edit += dispHit();
            continue;
        }
        // ヒット件数表示
        if (i == 0) edit += dispHit();
        // 書籍情報表示
        /* DB Schema
             0: kind    種別 0:俳句; 1:短歌; 2:その他
             1: image   画像ファイル名
             2: price   税込価格
             3: pinfo   付加情報
             4: title   タイトル
             5: subttl  サブタイトル
             6: author  著者名
             7: pubdate 刊行日
             8: isbn    ISBN
             9: comment コメント
        */
        // 左側: カバー画像
        edit += '<div class="book_frame">' +
            // カバー画像
            '<div class="book_left"><div class="book_img_frm">' +
            '<img class="book_img" src="./books/' + json[i].image + '" />' + 
            '</div><div class="book_pinfo">';
        // 新刊等の情報表示
        var newsign = '<span class="book_info">';
        var pinfo = json[i].pinfo;
        if (pinfo != "") {
            if (pinfo.match(/NEW/i)) {
                pinfo = pinfo.replace(/[\s\W]*NEW[\s\W]*/i, "");
                newsign += pinfo + 
                    '</span> <span class="book_new">NEW</span>';
            } else
                newsign += pinfo + '</span>';
        } else
            newsign = "";
        edit += newsign + '</div></div>'; // book_left end
        // 右側: 題名,著者名,価格,注文ボタン,刊行日,ISBN,コメント
        edit += '<div class="book_right"><div class="book_title">' +
            json[i].title + catSubTtl(json[i].subttl) + '</div>' +
            '<div class="book_author">' + json[i].author + '</div>' +
            '<div class="book_price"><div class="book_yen">&yen; ' + 
            insertComma(json[i].price) +
            ' (税込価格)</div>' + makeBuySnip(json[i]) + '</div>' +
            '<div class="book_pdate">' + json[i].pubdate + '</div>' +
            '<div class="book_isbn">' + json[i].isbn + '</div>' +
            '<div class="book_comm">' + json[i].comment + '</div>';
        edit += '</div></div>'; // book_right, book_frame end
    }
    // Prev/Nextページリンク表示
    edit = edit.replace(/&amp;#/g, "&#"); // 数値参照
    edit += makePageLink();
    // 編集結果を挿入
    $("#search_result").html(edit);
    // 先頭に遷移 (jQuery + easing)
    $('body,html').animate({
        scrollTop: 0
    }, {
        duration: 1000, easing: 'easeInOutCubic'
    });
}
 
// Prev/Next ページリンクを表示する
// - 最大,前後 5 ページ分,総 11 ページ分のページリンクを作成
// - 中央に現在ページをリンクなしで表示
// - prev / next を表示し前後ページリンクを付加する
//   <div class="search_link">
//     <div class="search_prev">Prev « [6のリンク] </div>
//     <div class="sprev">2[リンク付き]</div> [3 4 5 6 繰返]
//     <div class="scurt">7</div>
//     <div class="snext">8[リンク付き]</div> [9 10 11 12 繰返]
//     <div class="search_next"> » Next[8のリンク]</div>
//   </div>
// - リンク:
//   <a href="javascript:void(0);" onClick="bookSearch(0, offset);">no</a>
function makePageLink() {
    var snip = '<div class="search_link">';
    // 前リンク配列構築
    var wofset = sofset;
    var plist = new Array();
    for (var i = 0; i < 5; i++) {
        wofset -= slimit;
        if (wofset >= 0)
            plist[i] = wofset;
        else
            plist[i] = null;
    }
    // 後リンク配列構築
    wofset = sofset;
    var nlist = new Array();
    for (var i = 0; i < 5; i++) {
        wofset += slimit;
        if (wofset > scount)
            nlist[i] = null;
        else
            nlist[i] = wofset;
    }
    // 配列から前リンク生成
    snip += '<div class="search_prev">' + 
        makeAnchor(plist[0], "Prev « ") + '</div>';
    for (var i = 4; i >= 0; i--)
        snip += '<div class="sprev">' + makeAnchor(plist[i], null) + '</div>';
    // 現在ページ
    var pcur = (sofset / slimit) + 1;
    snip += '<div class="scurt">' + pcur + '</div>';
    // 配列から後リンク生成
    for (var i = 0; i < 5; i++)
        snip += '<div class="snext">' + makeAnchor(nlist[i], null) + '</div>';
    snip += '<div class="search_next">' + 
        makeAnchor(nlist[0], " » Next") + '</div></div>';
    return snip;
}
 
// ページアンカー生成
// - offset: DB検索の offset; ltext: アンカーテキスト
function makeAnchor(offset, ltext) {
    var atext = "";
    if (offset != null) {
        var page = (offset / slimit) + 1;
        atext += '<a href="javascript:void(0);" onClick="bookSearch(0, ' +
            offset + ');" title="(0, ' + offset + ')">';
        if (ltext != null)
            atext += ltext + '</a>';
        else
            atext += page + '</a>';
    } else {
        if (ltext != null)
            atext = ltext;
        else
            atext = " ";
    }
    return atext;
}
 
// ページ件数表示
function dispHit() {
    if (scount == 0)
        return '<div class="book_count">条件に合致する書籍はありませんでした。</div>';
    else
        return '<div class="book_count"><span>' + scount +
        '点あります。そのうち' + (sofset + 1) + '点目から' + calcEnd() + 
        '点目を表示しています。</span></div>';
}
 
// ページ最終案件数を計算する
function calcEnd() {
    if ((sofset + slimit) >= scount) // 最後のページ
        return scount; // ヒット件数
    else
        return (sofset + slimit); // オフセットから表示件数
}

BookSearch デプロイメントデスクリプタ

Java Servlet アプリケーションを Tomcat7 に配備するためのデプロイメントデスクリプタ web.xml を以下に示す。

ここで,dbpath, authsite という初期設定パラメータは,BookSearch アプリケーション独自の設定である。前者は SQLite3 書籍データベースのパス,後者はアプリケーション要求を受け付けるドメインの正規表現指定になっている。後者は特定サイト以外のページからの利用ができないようにするための工夫である(完全ではない)。

<!DOCTYPE web-app
    PUBLIC  "-//Sun Microsystems, Inc.//DTD Web Application 2.2//EN"
	"http://java.sun.com/j2ee/dtds/web-app_2_2.dtd">
<!-- -*- coding: utf-8; mode: xml; -*- -->
<!--
    BookSearch web.xml デプロイメントデスクリプタ
    Copyright (c) 2013, isao yasuda, All Rigths Reserved.
    $Id: web.xml 1 2013-12-20 17:01:27Z isao $
-->
<web-app>
 
  <!-- 説明 -->
  <display-name>二日酔書店 Web アプリケーション</display-name>
  <description>二日酔書店書籍検索</description>
 
  <!-- BookSearch サーブレット -->
  <servlet>
    <servlet-name>BookSearch</servlet-name>
    <servlet-class>BookSearch</servlet-class>
    <load-on-startup>1</load-on-startup>
 
	<!-- 初期設定パラメータ -->
    <!-- dbpath DBパス -->
    <init-param>
      <param-name>dbpath</param-name>
      <param-value>/usr/local/tomcat7/webapps/books/WEB-INF/classes/BOOK.db</param-value>
    </init-param>
    <!-- authsite 実行許可サイト -->
    <!-- - yasuda.homeip.net, beatrice: 本番機 -->
    <!-- - margarita, isolde: 開発機 -->
    <init-param>
      <param-name>authsite</param-name>
      <param-value>localhost|yasuda.homeip.net|margarita|isolde|beatrice</param-value>
    </init-param>
    <!-- debug デバッグモード -->
    <init-param>
      <param-name>debug</param-name>
      <param-value>true</param-value>
    </init-param>
  </servlet>
  
  <!-- BookSearch サーブレットのマッピング -->
  <servlet-mapping>
    <servlet-name>BookSearch</servlet-name>
    <url-pattern>/search</url-pattern>
  </servlet-mapping>
 
</web-app> 

BookSearch Ant ビルド・デプロイ用 build.xml

BookSearch Ant ビルド・デプロイ用 build.xml を以下に掲載する。

ここで,init (初期設定), compile (Java コンパイル), clean (クリーン), deploy (デプロイ), war (war: Web Archive ファイル作成) のレシピを定義している。

<?xml version="1.0" encoding="UTF-8"?>
<!-- -*- coding: UTF-8; -*-
	 ブックサーチ on insomnia Ant build 用 XML
	 $Id: build.xml 1 2013-12-20 17:01:27Z isao $
	 Copyright(c) 2013, isao yasuda, All Rights Reserved.
  -->
<project name="BookSearch" default="compile" basedir=".">
 
  <!-- 環境変数 -->
  <property environment="env" />
  <!-- ソースディレクトリ -->
  <property name="src.dir" value="src" />
  <!-- Web アーカイブディレクトリ -->
  <property name="war.dir" value="war" />
  <!-- クラスファイルディレクトリ -->
  <property name="class.dir" value="${war.dir}/WEB-INF/classes" />
  <!-- catalina lib -->
  <property name="lib.dir" value="${war.dir}/WEB-INF/lib" />
  <property name="catalina.lib" value="${env.CATALINA_HOME}/lib" />
  <!-- webapp ディレクトリ -->
  <property name="webapp.dir" 
	    value="${env.CATALINA_HOME}/webapps/books" />
  <!-- クラスパスの定義 -->
  <path id="ajax.class.path">
    <fileset dir="${lib.dir}"><include name="*.jar" /></fileset>
    <fileset dir="${catalina.lib}"><include name="*.jar"/></fileset>
  </path>
 
  <!-- クラスファイル出力ディレクトリの作成 -->
  <target name="init">
    <mkdir dir="${class.dir}" />
  </target>
 
  <!-- Java コンパイル -->
  <target name="compile" depends="init"
		  description="Compiles all source code.">
    <javac srcdir="${src.dir}" destdir="${class.dir}" debug="on"
           encoding="UTF-8" includeAntRuntime="true"
		   classpathref="ajax.class.path" />
  </target>
 
  <!-- クリーンアップ -->
  <target name="clean" description="Erases contents of classes dir">
    <delete dir="${class.dir}" />
  </target>
   
  <!-- デプロイ -->
  <target name="deploy" depends="compile"
		  description="Copies the contents of webapp to destination dir">
	<copy file="${src.dir}/log4j.xml" todir="${class.dir}"/>
	<copy file="bookdb/BOOK.db" todir="${class.dir}"/>
    <copy todir="${webapp.dir}">
      <fileset dir="${war.dir}" />
    </copy>
  </target>
 
  <!-- war 作成 -->
  <target name="war" depends="compile" description="Web Archive">
    <delete file="books.war" />
	<copy todir="${class.dir}">
	  <fileset dir="${src.dir}"><include name="log4j.xml" /></fileset>
	</copy>
	<copy todir="${class.dir}" file="bookdb/BOOK.db" />
	<war destfile="books.war" webxml="${war.dir}/WEB-INF/web.xml">
	  <lib dir="${lib.dir}" includes="*.jar" />
	  <classes dir="${class.dir}" />
	  <fileset dir="${war.dir}">
		<exclude name="**/WEB-INF/**" />
	  </fileset>
	</war>
  </target>
 
</project>

Java Servlet アプリケーション開発のリソースを格納するディレクトリ構成は図 1. のとおりである。build.xml のある books 直下ディレクトリにおいて ant war をコマンド発行すると,Java ソースがコンパイルされ,必要な Web アプリケーションリソースを纏めた books.war Web アーカイブファイルが生成される。この war タスクの定義においては,Web アーカイブに収録するに際して,データベースと log4j ロギング設定 log4j.xmlWEB-INF/classes ディレクトリ下にコピーするようにしている。

books-dir-structure.png
図 1. BookSearch Java Servlet 開発ディレクトリ構成

books.war を Tomcat7 manager (http://localhost:8080/manager/html) で配備するとアプリケーションが利用できるようになる。図 2. は Tomcat7 manager: WAR ファイルの配備を,図 3. は配備後アプリケーション一覧の表示を示す。

books-wapp-2.png
図 2. Tomcat7 manager: WAR ファイルの配備
books-wapp-3.png
図 3. 配備後アプリケーション一覧

ちょっと長々とし,ごちゃごちゃしてしまった。同じようなプログラムを必要とする方は必ずいると思う。少しでも参考になれば幸いである。有益な参考書もあげておく。

参考文献