ApacheのアクセスログをSQLで解析する

Apacheアクセスログ(CustomLog)を読み込みJoSQLを使って絞り込んだり集計したりしてみる。

前提

パーサの処理をはしょったりする為にいろいろ条件を決めておく。

  • ログ出力は空白区切り。(CLFやCombinedと呼ばれるような形式)
    • 空白を含む要素は「"」で囲む
    • 「"」内の「"」は「\"」とエスケープされている
  • 対応するフォーマットは「%h」「%l」「%u」「%t」「%r」「%s」「%b」「%{<ヘッダフィールド名>}i」のみ
    • 日付(%t)はCLFフォーマットのみ対応(%{}tは非対応)
    • 「%s」と「%>s」の区別は付けない
  • 要素の順序は入れ替え可能(LogFormatの書式で指定)

方針

  • コマンドラインからjavaを起動して実行する
    • sample.josql.LogQueryというクラスのmain()で処理を実行する。
    • クエリ文字列およびファイルパスはコマンドライン引数で渡す
    • 実行結果は標準出力にタブ区切りで出力する
  • ログ書式はシステムプロパティで変更出来るようにする
  • アクセスログの1行を1オブジェクトとして読み込む
    • LogRecordというクラスを作成してそこに保持
    • ヘッダのうちUserAgentとRefererだけはフィールドで保持。それ以外はMapで保持。

実行サンプル

毎回引数指定は面倒なのでaliasを定義。(bashを使っています。あとWindowsの場合クラスパスの区切りは「;」で。)

$ alias apache_query="java -Xmx1024m \
     -Dcustom_log.format='%h %l %u %t "%r" %>s %b "%{Referer}i" "%{User-Agent}i"'\
     -classpath 'lib/commons-lang-2.3.jar:lib/gentlyWEB-utils-1.1.jar:lib/JoSQL-2.0.jar:./classes/' \
     sample.josql.LogQuery"


logs/access_log.20081230というファイルの、全行についてstatus, requestTime,requestUriのみを表示

$ apache_query "select status, requestTime,requestUri from sample.custom_log.LogRecord" \
    logs/access_log.20081230
200	Tue Dec 30 00:00:00 JST 2008	/webapp/htm/logo.do?corner=10&id=9015
200	Tue Dec 30 00:00:00 JST 2008	/blog/list.php?blog_id=35
200	Tue Dec 30 00:00:01 JST 2008	/webapp/htm/logo.do?corner=10&id=13975
200	Tue Dec 30 00:00:02 JST 2008	/blog/list.php?blog_id=72
200	Tue Dec 30 00:00:02 JST 2008	/html/2008_goods/
200	Tue Dec 30 00:00:02 JST 2008	/cgi-bin/LB
200	Tue Dec 30 00:00:03 JST 2008	/webapp/htm/logo.do?corner=56&id=13423
200	Tue Dec 30 00:00:03 JST 2008	/blog/image/5861.jpg
.....


さらにpathが「/webapp/htm/logo.do」のもののみ表示

$ apache_query "select status, requestTime,requestUri from sample.custom_log.LogRecord
    where requestUri like '/webapp/htm/logo.do%'" logs/access_log.20081230
200	Tue Dec 30 00:00:00 JST 2008	/webapp/htm/logo.do?corner=10&id=9015
200	Tue Dec 30 00:00:01 JST 2008	/webapp/htm/logo.do?corner=10&id=13975
200	Tue Dec 30 00:00:03 JST 2008	/webapp/htm/logo.do?corner=56&id=13423
200	Tue Dec 30 00:00:04 JST 2008	/webapp/htm/logo.do?corner=65&id=11572
200	Tue Dec 30 00:00:04 JST 2008	/webapp/htm/logo.do?corner=10&id=13974
200	Tue Dec 30 00:00:04 JST 2008	/webapp/htm/logo.do?corner=47&id=14350
200	Tue Dec 30 00:00:05 JST 2008	/webapp/htm/logo.do?corner=38&id=7964
200	Tue Dec 30 00:00:05 JST 2008	/webapp/htm/logo.do?corner=38&id=11934
200	Tue Dec 30 00:00:05 JST 2008	/webapp/htm/logo.do?corner=47&id=14349
.....


一分毎の件数を集計

$ apache_query "select to_char(requestTime, 'yyyy/MM/dd:HH:mm'),
    count(to_char(requestTime, 'yyyy/MM/dd:HH:mm'))
    from sample.custom_log.LogRecord group by to_char(requestTime, 'yyyy/MM/dd:HH:mm')" \
  logs/access_log.20081230|sort
2008/12/30:00:00        146
2008/12/30:00:01        144
2008/12/30:00:02        150
2008/12/30:00:03        155
2008/12/30:00:04        146
2008/12/30:00:05        219
2008/12/30:00:06        207
2008/12/30:00:07        159
2008/12/30:00:08        172
2008/12/30:00:09        170
2008/12/30:00:10        160
.....


0時0分から0時10分の間のステータス毎の件数(左がstatusで右が件数)

$ apache_query "select status,count(status) from sample.custom_log.LogRecord
    where requestTime>=toDate('2008-12-30 00:00:00','yyyy-MM-dd HH:mm:ss') and 
           requestTime<toDate('2008-12-30 00:10:00','yyyy-MM-dd HH:mm:ss')
    group by status" \
  logs/access_log.20081230
206	1
404	1
200	1582
304	29
302	55


UserAgent毎の件数

$ apache_query "SELECT userAgent, count(userAgent) FROM sample.custom_log.LogRecord
    group by userAgent" \
  logs/access_log.local|sort -t"	" +1 -rn
Mozilla/4.0 (compatible; MSIE 5.5; Windows 98)  808
Mozilla/5.0 (compatible; Yahoo! Slurp; http://help.yahoo.com/help/us/ysearch/slurp)     680
Mozilla/5.0 (Twiceler-0.9 http://www.cuil.com/twiceler/robot.html)      264
Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)        252
Mozilla/4.0 (compatible; MSIE 7.0; Windows NT 5.1; .NET CLR 1.1.4322; .NET CLR 2.0.50727; InfoPath.1; .NET CLR 3.0.04506.30)    252
Mozilla/5.0 (Twiceler-0.9 http://www.cuill.com/twiceler/robot.html)     140
Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1; SV1; .NET CLR 2.0.50727; .NET CLR 1.1.4322)  114
Mozilla/5.0 (Macintosh; U; Intel Mac OS X 10_5_4; ja-jp) AppleWebKit/525.18 (KHTML, like Gecko) Version/3.1.2 Safari/525.20.1   113
Mozilla/4.0 (compatible; MSIE 6.0; Windows 98)  105
...


パラメータ「corner」の値毎の件数(左がcornerのIDで右が件数)

$ apache_query "SELECT get(param,'corner'), count(get(param,'corner')) FROM sample.custom_log.LogRecord
    where requestTime>=toDate('2008-12-30 00:00:00','yyyy-MM-dd HH:mm:ss') and 
           requestTime<toDate('2008-12-30 01:00:00','yyyy-MM-dd HH:mm:ss') and
           get(param,'corner') != null
    group by get(param,'corner')" \
  logs/access_log.20081230
18	6
0	2
53	3
60	102
1	4
50	178
39	17
26	67
7	27
70	51
62	123
36	36
....

ちなみにget()はMapの内容にアクセスする為の組み込み関数

環境

  • J2SE 5.0
  • JoSQL-2.0.jar (http://josql.sourceforge.net/から取得)
  • gentlyWEB-utils-1.1.jar (JoSQL付属のもの)
  • commons-lang-2.3.jar (ToStringBuilder, NotImplementedExceptionのみ使用)

JoSQLの修正

JoSQLをそのまま使うと、問い合わせ文の中で引用符を二組以上使うとエラーになる。
JoSQLは問い合わせ文のパースにJavaCCを使っているので、ソースコードのjjファイルの該当箇所を見てみる。

JoSQL-2.0/data/josql.jj:

...
TOKEN:
{
	< S_IDENTIFIER: ( <LETTER> )+ ( <DIGIT> | <LETTER> |<SPECIAL_CHARS>)* >
| 	< #LETTER: ["a"-"z", "A"-"Z"] >
|   < #SPECIAL_CHARS: "$" | "_">
| <S_CHAR_LITERAL:
   "'"
   (
         ~["\""]
       | "\""
   )*
   "'"
 >
| <S_CHAR_LITERAL2:
   "\""
   (
         ~["\""]
       | "\""
   )*
   "\""
 >
}

「シングルクォートに囲まれた、ダブルクォート以外またはダブルクォートの0回以上の繰り返し」みたいな感じになっていて、二カ所にシングルクォートがあると最外でマッチしてしまう。ので、これを修正

TOKEN:
{
	< S_IDENTIFIER: ( <LETTER> )+ ( <DIGIT> | <LETTER> |<SPECIAL_CHARS>)* >
| 	< #LETTER: ["a"-"z", "A"-"Z"] >
|   < #SPECIAL_CHARS: "$" | "_">
| <S_CHAR_LITERAL:
   "'"
   (
         ~["'"]
       | "\\'"
   )*
   "'"
 >
| <S_CHAR_LITERAL2:
   "\""
   (
         ~["\""]
       | "\\\""
   )*
   "\""
 >
}

JavaCCにかけてソースコードを再生成

../javacc-4.2/bin/javacc -OUTPUT_DIRECTORY=src/org/josql/parser/ data/josql.jj

Apacheのログパーサの実装

本題じゃないので軽くソースのみ。パーサ本体(LogParser)とフィールド分割するやつ(FieldSplitter)とログの一行分の情報を保持するやつ(LogRecord)と例外(LogParseException)。


まずはフィールド分割するやつ。設定値とログの読み込みの二カ所で使用。一行分をStringで渡すと区切ってString[]にして返してくれる。
FieldSplitter.java:

package sample.custom_log;

import java.util.ArrayList;
import java.util.List;
import java.util.StringTokenizer;


/**
 * LogFormat書式およびCustomLog出力結果をパースするクラス
 */
public class FieldSplitter {
    /** Apacheログの引用符(「"」) */
    private static final String QUOTE = "\"";
    /** Apacheログのセパレータ(半角空白) */
    private static final String SEPARATOR = " ";
    /** Apacheログのエスケープ文字(「\\」) */
    private static final String ESCAPE_CHAR = "\\";
    /** Apacheログの日付括弧開始(「[」) */
    private static final String DATE_OPENNER = "[";
    /** Apacheログの日付括弧終了(「]」) */
    private static final String DATE_CLOSER = "]";

    /**
     * デフォルトコンストラクタ
     *
     */
    public FieldSplitter() {
        super();
    }
    /**
     * Apacheログの一件分(一行)をフィールド毎に分割し、StringのListとして戻す
     * @param lineString 一行分の文字列
     * @return フィールド毎の文字列のList
     */
    public String[] splitLine(final String lineString) {
        List<String> fields = new ArrayList<String>();
        String delim = SEPARATOR + QUOTE + ESCAPE_CHAR + DATE_OPENNER + DATE_CLOSER;
        StringTokenizer st = new StringTokenizer(lineString, delim, true);
        Mode mode = Mode.NORMAL;
        StringBuffer buffer = new StringBuffer();
        while (st.hasMoreTokens()) {
            String token = st.nextToken();
            switch(mode) {
            case NORMAL:
                if (token.equals(SEPARATOR)) {
                    fields.add(buffer.toString());
                    buffer.setLength(0);
                } else if (token.equals(QUOTE)) {
                    mode = Mode.QUOTING;
                } else if (token.equals(DATE_OPENNER)) {
                    mode = Mode.IN_DATE_PART;
                } else {
                    buffer.append(token);
                }
                break;
            case QUOTING:
                if (token.equals(QUOTE)) {
                    mode = Mode.NORMAL;
                } else if (token.equals(ESCAPE_CHAR)) {
                    mode = Mode.ESCAPE;
                } else {
                    buffer.append(token);
                }
                break;
            case ESCAPE:
                buffer.append(token);
                mode = Mode.QUOTING;
                break;
            case IN_DATE_PART:
                if (token.equals(DATE_CLOSER)) {
                    mode = Mode.NORMAL;
                } else {
                    buffer.append(token);
                }
                break;
            default:
            }
        }
        if (buffer.length() != 0) {
            fields.add(buffer.toString());
        }
        return fields.toArray(new String[]{});
    }
    private enum Mode {
        /** 通常のパースモード */
        NORMAL,
        /** 引用符内のパースモード */
        QUOTING,
        /** エスケープ内のパースモード */
        ESCAPE,
        /** 日付括弧([])のパースモード */
        IN_DATE_PART
    }
}


ログの一行分の情報を保持するクラス。
LogRecord.java:

package sample.custom_log;

import java.util.Collections;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;

import org.apache.commons.lang.builder.ToStringBuilder;

/** 
 * Apacheのカスタムログの1リクエスト分の情報を保持するクラス。
 * cf: http://httpd.apache.org/docs/2.2/en/mod/mod_log_config.html
 */
public class LogRecord {
    public String toString() {
        return ToStringBuilder.reflectionToString(this);
    }
    private String remoteHost;
    private String remoteLogname;
    private String remoteUser;
    private Date requestTime;
    private String requestLine;
    private int status;
    private int responseSize;
    private String referer;
    private String userAgent;
    /** リクエストヘッダの内容を保持するMap。キー=ヘッダフィールド名、値=ヘッダ値 */
    private Map<String, String> requestHeaders = new HashMap<String, String>();
    /** Method/Request-URI/Protocol-VersonおよびURIのパラメータを保持するオブジェクト */
    private RequestLine requestLineObject = null;
    /** デフォルトコンストラクタ */
    public LogRecord() {
    }
    /* 検索/情報取得用の便利メソッド */
    /** @return リクエストメソッドを戻す */
    public String getMethod() {
        return (this.requestLineObject != null) 
                ? this.requestLineObject.getMethod() : null;
    }
    /** @return リクエストURIを戻す */
    public String getRequestUri() {
        return (this.requestLineObject != null) 
                ? this.requestLineObject.getRequestUri() : null;
    }
    /** @return リクエストパスを戻す */
    public String getRequestPath() {
        return (this.requestLineObject != null) 
                ? this.requestLineObject.getRequestPath() :null;
    }
    /** @return リクエストパラメータのMapを戻す */
    public Map<String, String> getParam() {
        return (this.requestLineObject != null) 
                ? this.requestLineObject.getParamMap()
                        :Collections.<String, String>emptyMap();
    }
    /** @return リクエストヘッダのMapを戻す */
    public Map<String, String> getHeader() {
        return requestHeaders;
    }

    /* getters */
    public String getRemoteHost() {
        return remoteHost;
    }
    public String getRemoteLogname() {
        return remoteLogname;
    }
    public String getRemoteUser() {
        return remoteUser;
    }
    public Date getRequestTime() {
        return requestTime;
    }
    public String getRequestLine() {
        return requestLine;
    }
    public int getStatus() {
        return status;
    }
    public int getResponseSize() {
        return responseSize;
    }
    public String getReferer() {
        return referer;
    }
    public String getUserAgent() {
        return userAgent;
    }
    /* setters */
    protected void setRemoteHost(String remoteHost) {
        this.remoteHost = remoteHost;
    }
    protected void setRemoteLogname(String remoteLogname) {
        this.remoteLogname = remoteLogname;
    }
    protected void setRemoteUser(String remoteUser) {
        this.remoteUser = remoteUser;
    }
    protected void setRequestTime(Date requestTime) {
        this.requestTime = requestTime;
    }
    /**
     * リクエスト行をセットする。同時にrequestLineObjectを更新する
     * @param requestLine リクエスト行(例:「GET / HTTP/1.0」)
     */
    protected void setRequestLine(String requestLine) {
        this.requestLine = requestLine;
        this.requestLineObject = new RequestLine(requestLine);
    }
    protected void setStatus(int status) {
        this.status = status;
    }
    protected void setReferer(String referer) {
        this.referer = referer;
    }
    protected void setUserAgent(String userAgent) {
        this.userAgent = userAgent;
    }
    protected void setResponseSize(int responseSize) {
        this.responseSize = responseSize;
    }
    protected void setRequestHeader(String name, String value) {
        requestHeaders.put(name, value);
    }
    /**
     * リクエスト行から情報を取り出し保持する為のクラス
     * リクエスト行の例: 「GET /a.cgi?category=aaa HTTP/1.1」
     */
    private static class RequestLine {
        /** HTTPメソッド。例: 「GET」 */
        private String method;
        /** リクエストURI。例: 「/a.cgi?category=aaa」 */
        private String requestUri;
        /** プロトコルとバージョン。例: 「HTTP/1.1」 */
        private String protocolVersion;

        /** リクエストURI。例: 「/a.cgi」 */
        private String requstPath;
        /** パラメータのMap。例: キー=「category」、値=「aaa」 (%エンコーディングはデコードしない)*/
        private Map<String, String> paramMap =  Collections.emptyMap();

        public String toString() {
            return ToStringBuilder.reflectionToString(this);
        }
        public RequestLine(String requstLine) {
            int length = requstLine.length();
            int cur, pos = 0;
            pos = requstLine.indexOf(' ');
            if (pos == -1) {
                method = requstLine;
                return;
            }
            method = requstLine.substring(0, pos);
            cur = pos + 1;
            pos = requstLine.indexOf(' ', cur);
            if (pos != -1) {
                requestUri = requstLine.substring(cur, pos);
                updatePathAndParam();
            } else {
                requestUri = requstLine.substring(cur, length);
                updatePathAndParam();
                return;
            }
            protocolVersion = requstLine.substring(pos + 1);
        }
        /** パスとパラメータを更新する */
        private void updatePathAndParam() {
            int queryPos = requestUri.indexOf('?');
            if (queryPos == -1) {
                requstPath = requestUri;
                return;
            }
            Map<String, String> tmpParams = new HashMap<String, String>();
            requstPath = requestUri.substring(0, queryPos);
            String queryString = requestUri.substring(queryPos + 1);
            
            String[] entries = queryString.split("&");
            for (String entry: entries) {
                int equalPos = entry.indexOf('=');
                String key = entry;
                String value = null;
                if (equalPos != -1) {
                    key = entry.substring(0, equalPos);
                    value = entry.substring(equalPos + 1);
                }
                tmpParams.put(key, value);
            }

            this.paramMap = Collections.unmodifiableMap(tmpParams);
        }

        public String getMethod() {
            return method;
        }
        public String getRequestUri() {
            return requestUri;
        }
        public String getProtocolVersion() {
            return protocolVersion;
        }
        public String getRequestPath() {
            return requstPath;
        }
        public Map<String, String> getParamMap() {
            return paramMap;
        }
    }
}


分割したフィールドをLogRecordにセットして返すクラス
LogParser.java:

package sample.custom_log;

import java.text.DateFormat;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.Locale;

/**
 * Apacheのログ文字列を読み込んでLogRecord化するためのクラス
 */
public class LogParser {
    private static final String DEFAULT_DATE_FORMAT = "dd/MMM/yyyy:HH:mm:ss Z";
    private static final String DEFAULT_LOG_FORMAT = "%h %l %u %t \"%r\" %>s %b";
    private String logFormat;
    /** フィールドに分割するためのもの */
    private FieldSplitter fieldSplitter = new FieldSplitter();
    /** 各フィールドの値をLogRecordにセットする為のもの */
    private FieldHandler[] handlers;
    
    /**
     * デフォルトコンストラクタ。フォーマットはDEFAULT_FORMAT(Apacheのcommon)を使用する。
     */
    public LogParser() {
        this(DEFAULT_LOG_FORMAT);
    }
    
    /**
     * フォーマット文字列を指定するコンストラクタ。
     * logFormatにはApacheのLogFormatで指定する文字列を設定する。
     * @param logFormat フォーマット文字列(非null)
     */
    public LogParser(String logFormat) {
        if (logFormat == null) {
            throw new IllegalArgumentException("format is null.");
        }
        this.logFormat = logFormat;
        try {
            initializeHandlers();
        } catch (LogParseException e) {
            e.printStackTrace();
            throw new IllegalArgumentException("Illegal format specified: " + logFormat);
        }
    }

    /**
     * 一行分のログをパースし、内容を新規に生成したLogRecordオブジェクトに設定して戻す。
     * @param logLine 一行分のログを含む文字列
     * @return ログの情報がセットされたLogRecord
     * @throws LogParseException フォーマットの不整合等、パース時の例外
     */
    public LogRecord parseLine(String logLine) throws LogParseException {
        if (logLine == null) {
            throw new LogParseException("Null input specified.");
        }
        final LogRecord logRecord = new LogRecord();
        String[] fieldValues = fieldSplitter.splitLine(logLine);
        if (fieldValues.length != this.handlers.length) {
            String fewOrMany = (fieldValues.length < this.handlers.length)
                        ? "few" : "many";
            throw new LogParseException("Too " + fewOrMany + " fields: " + fieldValues.length);
        }
        for (int i = 0; i < fieldValues.length && i < this.handlers.length; i++) {
            String fieldValue = fieldValues[i];
            FieldHandler handler = this.handlers[i];
            handler.setFieldValue(logRecord, fieldValue);
        }
        
        return logRecord;
    }
    
    /**
     * this.formatの内容にあわせてhandlersを初期化する。
     * フォーマット文字列のフィールド数と出力ログのフィールド数は一致する事が期待出来るため、
     * 各フィールドを処理するFieldHandlerを配列で準備する事で、ログを処理する際にフィールドの
     * 位置によって対応する処理をおこなうことができる。(例: formatの先頭が「%h」の場合、
     * handlers[0]にリモートホストを処理するFieldHandlerを格納しておく事で、配列のインデッ
     * クスによって適切なFieldHandlerを取得出来る。)
     * 途中で例外が発生した場合、handlersの内容は中途半端に初期化されていることがある。
     * @throws LogParseException フォーマット文字列の読み込み時の例外
     */
    private void initializeHandlers() throws LogParseException {
        String[] fieldFormats = fieldSplitter.splitLine(logFormat);
        this.handlers = new FieldHandler[fieldFormats.length];
        for (int index = 0; index < fieldFormats.length; index++ ) {
            String fieldFormat = fieldFormats[index];
            int formatLength = fieldFormat.length();
            // %からはじまらない場合は例外
            if (formatLength < 2 || !fieldFormat.startsWith("%")) {
                throw new IllegalArgumentException("Illegal format string:" + fieldFormat);
            }
            // %Xや%{hoge}XのXにあたる文字を取得
            char type = fieldFormat.charAt(formatLength - 1);
            // %{hoge}Xのhogeにあたる文字を取得
            String param = fieldFormat.substring(1, formatLength - 1);
            if (param.length() >=2 &&
                    param.startsWith("{") && param.endsWith("}")) {
                param = param.substring(1, param.length() - 1);
            }
            // そのフィールド用のFieldHandlerを生成
            FieldHandler handler = createHandler(type, param);
            if (handler == null) {
                throw new IllegalArgumentException("Unsupported format: " + fieldFormat);
            }
            this.handlers[index] = handler;
        }
    }

    /**
     * フォーマット文字列の内容にしたがってFieldHandlerを生成して戻す
     * @param type 「%{hoge}X」のXにあたる文字
     * @param param 「%xX」のx、または「%{hoge}X」のhogeにあたる文字列。%Xのようにtypeのみの場合は空文字列。
     * @return 生成したFieldHandlerオブジェクト
     * @see <a href="http://httpd.apache.org/docs/2.2/en/mod/mod_log_config.html"
     * >mod_log_config - Apache HTTP Server</a>
     */
    private FieldHandler createHandler(final char type, final String param) {
        switch (type) {
            case 'h':   // Remote host
                return new FieldHandler() {
                    public void setFieldValue(LogRecord logRecord, String value) {
                        logRecord.setRemoteHost(value);
                    }};
            case 'l':   // Remote logname (from identd, if supplied). 
                return new FieldHandler() {
                    public void setFieldValue(LogRecord logRecord, String value) {
                        logRecord.setRemoteLogname(value);
                    }};
            case 'u':   // Remote user. 
                return new FieldHandler() {
                    public void setFieldValue(LogRecord logRecord, String value) {
                        logRecord.setRemoteUser(value);
                    }};
            case 't':   // Time the request was received
                // TODO: paramが空でない場合はstrftimeフォーマットとして使用
                final DateFormat formatter = 
                    new SimpleDateFormat(DEFAULT_DATE_FORMAT, Locale.ENGLISH);
                return new FieldHandler() {
                    public void setFieldValue(LogRecord logRecord, String value) throws LogParseException {
                        try {
                            logRecord.setRequestTime(formatter.parse(value));
                        } catch (ParseException e) {
                            e.printStackTrace();
                            throw new LogParseException("Failed to parse requestTime:" + value);
                        }
                    }};
            case 'r':   // First line of request
                return new FieldHandler() {
                    public void setFieldValue(LogRecord logRecord, String value) {
                        logRecord.setRequestLine(value);
                    }};
            case 's':   // Status
                // この実装では%sと%>sの区別はつけない
                return new FieldHandler() {
                    public void setFieldValue(LogRecord logRecord, String value) throws LogParseException {
                        try {
                            logRecord.setStatus(Integer.parseInt(value));
                        } catch (NumberFormatException e) {
                            throw new LogParseException("Failed to parse status:" + value);
                        }
                    }};
            case 'b':   // Size of response in bytes
                return new FieldHandler() {
                    public void setFieldValue(LogRecord logRecord, String value) throws LogParseException {
                        int responseSize = 0;
                        if (!value.equals("-")) {
                            try {
                                responseSize = Integer.parseInt(value);
                            } catch (NumberFormatException e) {
                                e.printStackTrace();
                                throw new LogParseException("Failed to parse status:" + value);
                            }
                        }
                        logRecord.setResponseSize(responseSize);
                    }};
            case 'i':   // The contents of Foobar: header line(s) in the request sent to the server. 
                if (param.equalsIgnoreCase("referer")) {
                    return new FieldHandler() {
                        public void setFieldValue(LogRecord logRecord, String value) {
                           logRecord.setReferer(value);
                        }};
                } else if (param.equalsIgnoreCase("user-agent")) {
                    return new FieldHandler() {
                        public void setFieldValue(LogRecord logRecord, String value) {
                           logRecord.setUserAgent(value);
                        }};
                } else {
                    return new FieldHandler() {
                        public void setFieldValue(LogRecord logRecord, String value) {
                           logRecord.setRequestHeader(param, value);
                        }};
                }
            // 他の項目の対応も必要ならここに付け足す
            default: // 未対応のフォーマットについては、何もしないFieldHandlerを戻す
                return new FieldHandler() {
                    public void setFieldValue(LogRecord logRecord, String value) {
                    }};
        }
    }
    public interface FieldHandler {
        /**
         * ログの各フィールドの値をlogRecordの該当する項目にセットする。
         * @param logRecord LogRecordオブジェクト
         * @param value フィールド値(文字列)
         * @throws LogParseException パース時にフォーマットの不一致などの例外が発生した場合、この例外を投げる事
         */
        void setFieldValue(LogRecord logRecord, String value) throws LogParseException;
    }
}


最後に例外。
LogParseException.java:

package sample.custom_log;

public class LogParseException extends Exception {
    /** シリアルバージョン番号 */
    private static final long serialVersionUID = -3318103984912270740L;

    public LogParseException(String message) {
        super(message);
    }
}

LogQuery本体の実装

コマンドライン引数にクエリ文字列とファイルパスを取って、ファイルからログを読み込みJoSQLに掛けた結果を出力するクラスを実装する。
クラスとしての再利用性は多分あんまり無いけど整理の為にクラス化。


main()にあるように、「初期化」、「クエリを実行」、「結果を出力」、の手順で処理をおこなう。


「クエリを実行(execute())」時にログの内容を全てメモリ上に読み込んで、org.josql.Queryで検索に掛けている。


出力結果はgroup byを使う場合塗装でない場合で処理内容が異なる。(これはJoSQLの仕様で、プログラム的に使うにはその方が便利らしい。)


あと、独自の拡張としてto_char(日付, フォーマット)という関数を使えるように、DateFunctionHandlerというクラスを作成し、QueryクラスのaddFunctionHandler()で追加した。


LogQuery.java:

package sample.josql;

import java.io.BufferedReader;
import java.io.FileReader;
import java.io.IOException;
import java.text.DecimalFormat;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Date;
import java.util.Iterator;
import java.util.List;
import java.util.Map;

import org.josql.Query;
import org.josql.QueryExecutionException;
import org.josql.QueryParseException;
import org.josql.QueryResults;
import org.josql.functions.AbstractFunctionHandler;

import sample.custom_log.LogParseException;
import sample.custom_log.LogParser;
import sample.custom_log.LogRecord;

/** JoSQLを使用してApacheのアクセスログを処理するクラス */
public class LogQuery {
    private static final String CUSTOM_LOG_FORMAT_PROP_KEY = "custom_log.format";
    private static final String LOG_FOARMAT =
        "%h %l %u %t \"%r\" %>s %b \"%{Referer}i\" \"%{User-Agent}i\"";
    private String[] paths;
    private Query query = new Query();
    private QueryResults queryResults;

    public static void main(String[] args) {
        if (args.length < 2) {
            System.err.println("Error: Too few args.");
            System.err.println("Usage: java " + LogQuery.class +
                    " <query string> <filpath> [<filepath> ...]");
            System.exit(-1);
        }
        String query = args[0];
        String[] paths = new String[args.length - 1];
        System.arraycopy(args, 1, paths, 0, paths.length);
        
        LogQuery logQuery;
        try {
            logQuery = new LogQuery(query, paths);
            logQuery.execute();
            logQuery.printResults();
        } catch (QueryParseException e) {
            e.printStackTrace();
        } catch (QueryExecutionException e) {
            e.printStackTrace();
        }
    }
    public static class DateFunctionHandler extends AbstractFunctionHandler {
        public String to_char(Date date, String format) {
            return new SimpleDateFormat(format).format(date);
        }
    }
    /**
     * @param queryString クエリ文字列
     * @param paths 読み込むログのパス
     */
    public LogQuery(String queryString, String[] paths) throws QueryParseException {
        if (queryString == null) {
            throw new IllegalArgumentException("queryString is null.");
        }
        if (paths == null || paths.length == 0) {
            throw new IllegalArgumentException("paths is empty.");
        }
        this.query.addFunctionHandler(new DateFunctionHandler());

        this.query.parse(queryString);
        this.paths = paths.clone();
    }
    /** クエリを実行する */
    public void execute() throws QueryExecutionException {
        List<LogRecord> records = readLogs();
        this.queryResults = this.query.execute(records);
    }

    /**
     * pathsに設定されたパスからApacheのログファイルを読み込む。
     * @return 読み込んだログの内容を含むLogRecordのリスト
     */
    private List<LogRecord> readLogs() {
        String format = System.getProperty(CUSTOM_LOG_FORMAT_PROP_KEY, LOG_FOARMAT);
        LogParser parser = new LogParser(format);
        List<LogRecord> records = new ArrayList<LogRecord>();
        for (String path: this.paths) {
            List<LogRecord> recordsForFile;
            try {
                recordsForFile = readLog(path, parser);
                records.addAll(recordsForFile);
            } catch (IOException e) {
                e.printStackTrace();
                System.err.println("Read Error in file:" + path + " : " + e.getMessage());
            }
        }
        return records;
    }
    /**
     * 1ファイルからログを読み込む
     * @param path ファイルパス
     * @param parser パーサー
     * @return 読み込んだログの内容を含むLogRecordのリスト
     * @throws IOException ファイル読み込み時のIO例外
     */
    private List<LogRecord> readLog(String path, LogParser parser) throws IOException {
        List<LogRecord> records = new ArrayList<LogRecord>();
        FileReader fileReader = new FileReader(path);
        try {
            BufferedReader reader = new BufferedReader(fileReader);
            int lineNumber = 0;
            String line;
            while ((line = reader.readLine()) != null) {
                lineNumber++;
                try {
                    LogRecord logRecord = parser.parseLine(line);
                    records.add(logRecord);
                } catch (LogParseException e) {
                    e.printStackTrace();
                    System.err.println("Parse Error at line:" + lineNumber +
                            " in file:" + path + " : " + e.getMessage());
                }
            }
            return records;
        } finally {
            fileReader.close();
        }
    }
    /**
     * 結果出力をおこなう
     */
    public void printResults() {
        Map groupByResults = this.queryResults.getGroupByResults();
        if (groupByResults == null) {
            printQueryResults(this.queryResults.getResults());
        } else {
            printGroupByResults(this.queryResults.getResults(), groupByResults);
        }
    }
    /**
     * 通常の結果出力をおこなう
     * @param results 結果リスト
     */
    private void printQueryResults(List results) {
        for (Object o: results) {
            printSingleResult(o);
        }
    }
    /**
     * group by 使用時の結果出力をおこなう
     * @param results 結果リスト
     * @param groupByResults QueryResultsから取得したgroup byの結果
     */
    private void printGroupByResults(List results, Map groupByResults) {
        for (Object groupItem: results) {
            List eachResults = (List) groupByResults.get(groupItem);
            printSingleResult(eachResults.isEmpty() ? "" : eachResults.get(0));
        }
    }
    /**
     * 一行分のオブジェクトを出力する。
     * Listなら要素をタブ区切りで、そうでない場合はtoString()の結果を書き出す。
     * @param resultObject 出力するオブジェクト
     */
    private void printSingleResult(Object resultObject) {
        if (resultObject instanceof List) {
            Iterator it = ((List) resultObject).iterator();
            while (it.hasNext()) {
                System.out.print(it.next());
                if (it.hasNext()) {
                    System.out.print("\t");
                }
            }
            System.out.println();
        } else {
            System.out.println(resultObject.toString());
        }
    }
}

感想

RDBMSに関わらず、データを集合的に扱うにはSQL的な記述方法は結構便利だ。一旦、問い合わせ文を作ればデイリーの集計にもリアルタイムのウォッチにも使えるし。


ということを言おうと思ったけど、使い勝手上、若干微妙な結果だった。処理は遅いし、副問い合わせなども出来ないので「○○のページを訪問したユーザが次に訪問するページの割合」みたいなのを出すのにも使えない。これぐらいならawkperlワンライナーで解析する方が楽だし融通が利く気がする。(普段はawkでやる事が多い。)


DB上のデータと同様の使い勝手でメモリ上のコレクションを絞り込んだりソートしたりするのには需要はあると思う。WebObjectsを使っていた頃はEOQualifier.html#filteredArrayWithQualifier()をよく使っていた。


CayenneのExpressionも似たような感じで使えそうなので使ってみたい。絞り込めるのはDBから取って来たCollectionだけ、みたいなケチ臭いこと言わないよね?

(おまけ)LogParserを高速化したよ

あまりに遅いのでちょっと速くしてみた。「早すぎる最適化は諸悪の根源」ということらしいので後にした。
可読性や安全性(エラーチェックしてないとか)に問題があったりするので、コードの内容はあまり参考にしないで欲しい。


まずFieldSplitterを以下の方針で修正。下の方はあんまり効果ないかも。

  • StringTokenizerが重いので自分でcharAt()でなめるようにした。
  • StringBufferをStringBuilderに変更(ローカルでの一時的な使用はスレッドセーフである必要ないので)
  • StringBuilderをフィールドに移動(毎回newすると遅いので。代わりに完全にスレッドセーフではなくなる。)
  • 可能な限りsubstring()を使用する。Java5の実装見るとString#substring(int,int)は内部のchar配列をコピーしないので早いっぽい。
  • ArrayListや配列を作っていると遅いのでフィールドを読み取った時点でイベントドリブンな感じでハンドラ呼び出し。
  • ケースが少ない場合はswitchよりifの方が早そうだったのでifに変更
  • エスケープを引用符だけじゃなくて任意の場所で有効にし、フラグ(isEscaping)に変更
package sample.custom_log;

/**
 * LogFormat書式およびCustomLog出力結果をパースするクラス。
 * スレッドセーフではない!
 */
public class FieldSplitter {
    private static final int DEFAULT_BUFFER_SIZE = 1024;
    /** エスケープ文字(「\\」) */
    private static final char ESCAPE_CHAR = '\\';
    /** セパレータ(半角空白) */
    private static final char SEPARATOR = ' ';
    /** 引用符(「"」) */
    private static final char QUOTE = '\"';
    /** 括弧開始(「[」) */
    private static final char DATE_OPENNER = '[';
    /** 括弧終了(「]」) */
    private static final char DATE_CLOSER = ']';
    /** thisが共用するStringBuilder */
    private StringBuilder buffer = new StringBuilder(DEFAULT_BUFFER_SIZE);
    public FieldSplitter() {
        
    }
    /**
     * Apacheログの一件分(一行)からフィールドを読み込み、順次fieldSplitterHandlerのhandleFieldValue()を呼び出す。
     * 
     * @param lineString 一行分の文字列
     * @param fieldSplitterHandler 読み取ったフィールドを処理するFieldSplitterHandler
     * @throws LogParseException 引用符が閉じていない等、フォーマットがおかしい場合の例外
     */
    public void splitLine(String lineString, FieldSplitterHandler fieldSplitterHandler)
            throws LogParseException {
        int length = lineString.length();
        Mode mode = Mode.NORMAL;
        boolean isEscaping = false;
        buffer.setLength(0);
        int pos = 0;
        int start = 0;
        for (pos = 0; pos < length; pos++) {
            char c = lineString.charAt(pos);
            if (isEscaping) {
                isEscaping = false;
                start = pos;
            } else if (c == ESCAPE_CHAR) {
                isEscaping = true;
                if (pos - start > 0) {
                    buffer.append(lineString, start, pos);
                }
                start = pos + 1;
            } else {
                if (mode == Mode.NORMAL) {
                    if (c == SEPARATOR) {
                        if (pos > start) {
                            String value = fullFieldValue(buffer, lineString, start, pos);
                            fieldSplitterHandler.handleFieldValue(value);
                        }
                        start = pos + 1;
                    } else if (c == QUOTE) {
                        mode = Mode.QUOTING;
                        start = pos + 1;
                    } else if (c == DATE_OPENNER) {
                        mode = Mode.IN_DATE_PART;
                        start = pos + 1;
                    }
                } else if (mode == Mode.QUOTING) {
                    if (c == QUOTE) {
                        mode = Mode.NORMAL;
                        String value = fullFieldValue(buffer, lineString, start, pos);
                        fieldSplitterHandler.handleFieldValue(value);
                        start = pos + 1;
                    }
                } else if (mode == Mode.IN_DATE_PART) {
                    if (c == DATE_CLOSER) {
                        mode = Mode.NORMAL;
                        String value = fullFieldValue(buffer, lineString, start, pos);
                        fieldSplitterHandler.handleFieldValue(value);
                        start = pos + 1;
                    }
                }
            }
        }
        if (mode != Mode.NORMAL) {
            throw new LogParseException("Unbalance spchar.");
        }
        if (pos > start) {
            String value = fullFieldValue(buffer, lineString, start, pos);
            fieldSplitterHandler.handleFieldValue(value);
        }
    }
    /**
     * bufferの内容およびstartからposまでの間の文字列を連結したものを戻す。
     */
    private static String fullFieldValue(
            StringBuilder buffer, String lineString, int start, int pos) {
        String value;
        if (buffer.length() > 0) {
            if (pos - start > 0) {
                buffer.append(lineString, start, pos);
            }
            value = buffer.toString();
            buffer.setLength(0);
        } else {
            value = lineString.substring(start, pos);
        }
        return value;
    }
    private enum Mode {
        /** 通常 */
        NORMAL,
        /** 引用符内 */
        QUOTING,
        /** 日付括弧([])内 */
        IN_DATE_PART
    }
    
    public interface FieldSplitterHandler {
        void handleFieldValue(String fieldValue) throws LogParseException;
    }
}

LogParserを以下の方針で修正。

  • FieldSplitterをイベントドリブンにしたのに合わせて変更
  • SimpleDateFormatでのparseが遅いので、専用のCLFDateFormatというクラスを作成して使用
    • フォーマットは「dd/MMM/yyyy:HH:mm:ss Z」に固定
    • ログファイル内でゾーンは変わらないことを仮定してゾーンのパースを省略
    • 年月日部分はあまり変わらないということで、Mapを使ってキャッシュ
    • 時分秒の部分は算術的に計算する。
    • 真面目に入力値のエラー処理しない


CLFDateFormat.java:

package sample.custom_log;

import java.text.DateFormat;
import java.text.FieldPosition;
import java.text.ParseException;
import java.text.ParsePosition;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.Locale;
import java.util.Map;
import java.util.TreeMap;

import org.apache.commons.lang.NotImplementedException;

/** 
 * CLFのパースに特化したDateFormat。
 * スレッドセーフではない!
 */
public class CLFDateFormat extends DateFormat {
    private static final String DEFAULT_DATE_FORMAT = "dd/MMM/yyyy:HH:mm:ss Z";
    private static final long serialVersionUID = 4124093846593771052L;
    private Map<String, Long>dayTimes = new TreeMap<String, Long>();
    final DateFormat formatter = new SimpleDateFormat(DEFAULT_DATE_FORMAT, Locale.ENGLISH);
    public CLFDateFormat() {
        
    }
    @Override
    public StringBuffer format(Date date, StringBuffer stringbuffer,
            FieldPosition fieldposition) {
        throw new NotImplementedException();
    }

    @Override
    public Date parse(String s, ParsePosition parseposition) {
        int pos = parseposition.getIndex();
        String dayString = s.substring(pos, pos + 12);
        String zoneString = s.substring(pos + 21, pos + 26);
        long dayTime;
        try {
            dayTime = getDayTime(dayString, zoneString);
        } catch (ParseException e) {
            e.printStackTrace();
            return null;
        }
        // dd/MMM/yyyy:HH:mm:ss Z
        // 19/Dec/2008:09:03:24 +0900
        char hourH = s.charAt(pos + 12);
        char hourL = s.charAt(pos + 13);
        char minH = s.charAt(pos + 15);
        char minL = s.charAt(pos + 16);
        char secH = s.charAt(pos + 18);
        char secL = s.charAt(pos + 19);
        dayTime = dayTime + 
            ((((hourH - '0') * 10 + (hourL - '0')) * 60)
                + ((minH - '0') * 10 + (minL - '0'))) * 60
                    + ((secH - '0') * 10 + (secL - '0')) * 1000;
        parseposition.setIndex(pos + 26);
        return new Date(dayTime);
    }
    private long getDayTime(String dayString, String zoneString) throws ParseException {
        Long daytime = dayTimes.get(dayString);
        if (daytime == null) {
            String dateString = dayString + "00:00:00 " + zoneString;
            daytime = formatter.parse(dateString).getTime();
            dayTimes.put(dayString, daytime);
        }
        return daytime.longValue();
    }
}


LogParser.javaの変更点は、DateFormatのクラスとFieldSplitterのイベント駆動化対応のみ。

package sample.custom_log;

import java.text.DateFormat;
import java.text.ParseException;
import java.util.ArrayList;
import java.util.List;

import sample.custom_log.FieldSplitter.FieldSplitterHandler;

/**
 * Apacheのログ文字列を読み込んでLogRecord化するためのクラス
 */
public class LogParser {
    private static final String DEFAULT_LOG_FORMAT = "%h %l %u %t \"%r\" %>s %b";
    private String logFormat;
    /** フィールドに分割するためのもの */
    private FieldSplitter fieldSplitter = new FieldSplitter();
    /** 各フィールドの値をLogRecordにセットする為のもの */
    private FieldHandler[] handlers;
    
    /**
     * デフォルトコンストラクタ。フォーマットはDEFAULT_FORMAT(Apacheのcommon)を使用する。
     */
    public LogParser() {
        this(DEFAULT_LOG_FORMAT);
    }
    
    /**
     * フォーマット文字列を指定するコンストラクタ。
     * logFormatにはApacheのLogFormatで指定する文字列を設定する。
     * @param logFormat フォーマット文字列(非null)
     */
    public LogParser(String logFormat) {
        if (logFormat == null) {
            throw new IllegalArgumentException("format is null.");
        }
        this.logFormat = logFormat;
        try {
            initializeHandlers();
        } catch (LogParseException e) {
            e.printStackTrace();
            throw new IllegalArgumentException("Illegal format specified: " + logFormat);
        }
    }

    /**
     * 一行分のログをパースし、内容を新規に生成したLogRecordオブジェクトに設定して戻す。
     * @param logLine 一行分のログを含む文字列
     * @return ログの情報がセットされたLogRecord
     * @throws LogParseException フォーマットの不整合等、パース時の例外
     */
    public LogRecord parseLine(String logLine) throws LogParseException {
        if (logLine == null) {
            throw new LogParseException("Null input specified.");
        }
        final LogRecord logRecord = new LogRecord();
        this.fieldSplitter.splitLine(logLine, new FieldSplitterHandler() {
            private int fieldCount = 0;
            public void handleFieldValue(String fieldValue) throws LogParseException {
                if (fieldCount < handlers.length) {
                    handlers[fieldCount++].setFieldValue(logRecord, fieldValue);
                } else {
                    throw new LogParseException("Too Many fields. Over " + fieldCount);
                }
            }
        });
        return logRecord;
    }
    
    /**
     * this.formatの内容にあわせてhandlersを初期化する。
     * フォーマット文字列のフィールド数と出力ログのフィールド数は一致する事が期待出来るため、
     * 各フィールドを処理するFieldHandlerを配列で準備する事で、ログを処理する際にフィールドの
     * 位置によって対応する処理をおこなうことができる。(例: formatの先頭が「%h」の場合、
     * handlers[0]にリモートホストを処理するFieldHandlerを格納しておく事で、配列のインデッ
     * クスによって適切なFieldHandlerを取得出来る。)
     * 途中で例外が発生した場合、handlersの内容は中途半端に初期化されていることがある。
     * @throws LogParseException フォーマット文字列の読み込み時の例外
     */
    private void initializeHandlers() throws LogParseException {
        final List<String> fieldFormats = new ArrayList<String>();
        fieldSplitter.splitLine(logFormat, new FieldSplitterHandler() {
            public void handleFieldValue(String fieldValue) {
                fieldFormats.add(fieldValue);
            }
        });
        this.handlers = new FieldHandler[fieldFormats.size()];
        for (int index = 0; index < fieldFormats.size(); index++ ) {
            String fieldFormat = fieldFormats.get(index);
            int formatLength = fieldFormat.length();
            // %からはじまらない場合は例外
            if (formatLength < 2 || !fieldFormat.startsWith("%")) {
                throw new IllegalArgumentException("Illegal format string:" + fieldFormat);
            }
            // %Xや%{hoge}XのXにあたる文字を取得
            char type = fieldFormat.charAt(formatLength - 1);
            // %{hoge}Xのhogeにあたる文字を取得
            String param = fieldFormat.substring(1, formatLength - 1);
            if (param.length() >=2 &&
                    param.startsWith("{") && param.endsWith("}")) {
                param = param.substring(1, param.length() - 1);
            }
            // そのフィールド用のFieldHandlerを生成
            FieldHandler handler = createHandler(type, param);
            if (handler == null) {
                throw new IllegalArgumentException("Unsupported format: " + fieldFormat);
            }
            this.handlers[index] = handler;
        }
    }

    /**
     * フォーマット文字列の内容にしたがってFieldHandlerを生成して戻す
     * @param type 「%{hoge}X」のXにあたる文字
     * @param param 「%xX」のx、または「%{hoge}X」のhogeにあたる文字列。%Xのようにtypeのみの場合は空文字列。
     * @return 生成したFieldHandlerオブジェクト
     * @see <a href="http://httpd.apache.org/docs/2.2/en/mod/mod_log_config.html"
     * >mod_log_config - Apache HTTP Server</a>
     */
    private FieldHandler createHandler(final char type, final String param) {
        switch (type) {
            case 'h':   // Remote host
                return new FieldHandler() {
                    public void setFieldValue(LogRecord logRecord, String value) {
                        logRecord.setRemoteHost(value);
                    }};
            case 'l':   // Remote logname (from identd, if supplied). 
                return new FieldHandler() {
                    public void setFieldValue(LogRecord logRecord, String value) {
                        logRecord.setRemoteLogname(value);
                    }};
            case 'u':   // Remote user. 
                return new FieldHandler() {
                    public void setFieldValue(LogRecord logRecord, String value) {
                        logRecord.setRemoteUser(value);
                    }};
            case 't':   // Time the request was received
                // TODO: paramが空でない場合はstrftimeフォーマットとして使用
                final DateFormat formatter = 
                    new CLFDateFormat();
                return new FieldHandler() {
                    public void setFieldValue(LogRecord logRecord, String value) throws LogParseException {
                        try {
                            logRecord.setRequestTime(formatter.parse(value));
                        } catch (ParseException e) {
                            e.printStackTrace();
                            throw new LogParseException("Failed to parse requestTime:" + value);
                        }
                    }};
            case 'r':   // First line of request
                return new FieldHandler() {
                    public void setFieldValue(LogRecord logRecord, String value) {
                        logRecord.setRequestLine(value);
                    }};
            case 's':   // Status
                // この実装では%sと%>sの区別はつけない
                return new FieldHandler() {
                    public void setFieldValue(LogRecord logRecord, String value) throws LogParseException {
                        try {
                            logRecord.setStatus(Integer.parseInt(value));
                        } catch (NumberFormatException e) {
                            throw new LogParseException("Failed to parse status:" + value);
                        }
                    }};
            case 'b':   // Size of response in bytes
                return new FieldHandler() {
                    public void setFieldValue(LogRecord logRecord, String value) throws LogParseException {
                        int responseSize = 0;
                        if (!value.equals("-")) {
                            try {
                                responseSize = Integer.parseInt(value);
                            } catch (NumberFormatException e) {
                                e.printStackTrace();
                                throw new LogParseException("Failed to parse status:" + value);
                            }
                        }
                        logRecord.setResponseSize(responseSize);
                    }};
            case 'i':   // The contents of Foobar: header line(s) in the request sent to the server. 
                if (param.equalsIgnoreCase("referer")) {
                    return new FieldHandler() {
                        public void setFieldValue(LogRecord logRecord, String value) {
                           logRecord.setReferer(value);
                        }};
                } else if (param.equalsIgnoreCase("user-agent")) {
                    return new FieldHandler() {
                        public void setFieldValue(LogRecord logRecord, String value) {
                           logRecord.setUserAgent(value);
                        }};
                } else
                
                return new FieldHandler() {
                    public void setFieldValue(LogRecord logRecord, String value) {
                       logRecord.setRequestHeader(param, value);
                    }};
                    // 他の項目の対応も必要ならここに付け足す
            default: // 未対応のフォーマットについては、何もしないFieldHandlerを戻す
                return new FieldHandler() {
                    public void setFieldValue(LogRecord logRecord, String value) {
                }};
        }
    }
    public interface FieldHandler {
        /**
         * ログの各フィールドの値をlogRecordの該当する項目にセットする。
         * @param logRecord LogRecordオブジェクト
         * @param value フィールド値(文字列)
         * @throws LogParseException パース時にフォーマットの不一致などの例外が発生した場合、この例外を投げる事
         */
        void setFieldValue(LogRecord logRecord, String value) throws LogParseException;
    }
}


最後にLogRecordを以下の方針で修正。

  • String#split()が遅いので自分でcharAt()でなめるようにした。
  • 可能な限りString#substring()を使用する。
  • HashMapをTreeMapに変更(要素数が少ない時は有利そう)
  • Mapは初期化時にはCollections.emptyMap()を入れておき、変更のある時だけ可変のMapをnew()
  • unmodifiable化はしない
package sample.custom_log;

import java.util.Collections;
import java.util.Date;
import java.util.Map;
import java.util.TreeMap;

import org.apache.commons.lang.builder.ToStringBuilder;

/** 
 * Apacheのカスタムログの1リクエスト分の情報を保持するクラス。
 * cf: http://httpd.apache.org/docs/2.2/en/mod/mod_log_config.html
 */
public class LogRecord {
    public String toString() {
        return ToStringBuilder.reflectionToString(this);
    }
    private String remoteHost;
    private String remoteLogname;
    private String remoteUser;
    private Date requestTime;
    private String requestLine;
    private int status;
    private int responseSize;
    private String referer;
    private String userAgent;
    /** リクエストヘッダの内容を保持するMap。キー=ヘッダフィールド名、値=ヘッダ値 */
    private Map<String, String> requestHeaders = Collections.emptyMap();
    /** Method/Request-URI/Protocol-VersonおよびURIのパラメータを保持するオブジェクト */
    private RequestLine requestLineObject = null;
    /** デフォルトコンストラクタ */
    public LogRecord() {
    }
    /* 検索/情報取得用の便利メソッド */
    /** @return リクエストメソッドを戻す */
    public String getMethod() {
        return (this.requestLineObject != null) 
                ? this.requestLineObject.getMethod() : null;
    }
    /** @return リクエストURIを戻す */
    public String getRequestUri() {
        return (this.requestLineObject != null) 
                ? this.requestLineObject.getRequestUri() : null;
    }
    /** @return リクエストパスを戻す */
    public String getRequestPath() {
        return (this.requestLineObject != null) 
                ? this.requestLineObject.getRequestPath() :null;
    }
    /** @return リクエストパラメータのMapを戻す */
    public Map<String, String> getParam() {
        return (this.requestLineObject != null) 
                ? this.requestLineObject.getParamMap()
                        :Collections.<String, String>emptyMap();
    }
    /** @return リクエストヘッダのMapを戻す */
    public Map<String, String> getHeader() {
        return this.requestHeaders;
    }

    /* getters */
    public String getRemoteHost() {
        return remoteHost;
    }
    public String getRemoteLogname() {
        return remoteLogname;
    }
    public String getRemoteUser() {
        return remoteUser;
    }
    public Date getRequestTime() {
        return requestTime;
    }
    public String getRequestLine() {
        return requestLine;
    }
    public int getStatus() {
        return status;
    }
    public int getResponseSize() {
        return responseSize;
    }
    public String getReferer() {
        return referer;
    }
    public String getUserAgent() {
        return userAgent;
    }
    /* setters */
    protected void setRemoteHost(String remoteHost) {
        this.remoteHost = remoteHost;
    }
    protected void setRemoteLogname(String remoteLogname) {
        this.remoteLogname = remoteLogname;
    }
    protected void setRemoteUser(String remoteUser) {
        this.remoteUser = remoteUser;
    }
    protected void setRequestTime(Date requestTime) {
        this.requestTime = requestTime;
    }
    /**
     * リクエスト行をセットする。同時にrequestLineObjectを更新する
     * @param requestLine リクエスト行(例:「GET / HTTP/1.0」)
     */
    public void setRequestLine(String requestLine) {
        this.requestLine = requestLine;
        this.requestLineObject = new RequestLine(requestLine);
    }
    protected void setStatus(int status) {
        this.status = status;
    }
    protected void setReferer(String referer) {
        this.referer = referer;
    }
    protected void setUserAgent(String userAgent) {
        this.userAgent = userAgent;
    }
    protected void setResponseSize(int responseSize) {
        this.responseSize = responseSize;
    }
    /**
     * リクエストヘッダをセットする
     * @param name ヘッダフィールド名
     * @param value ヘッダフィールド値
     */
    public void setRequestHeader(String name, String value) {
        if (requestHeaders == Collections.<String,String>emptyMap()) {
            requestHeaders = new TreeMap<String, String>();
        }
        requestHeaders.put(name, value);
    }
    /**
     * リクエスト行から情報を取り出し保持する為のクラス
     * リクエスト行の例: 「GET /a.cgi?category=aaa HTTP/1.1」
     */
    private static class RequestLine {
        /** HTTPメソッド。例: 「GET」 */
        private String method;
        /** リクエストURI。例: 「/a.cgi?category=aaa」 */
        private String requestUri;
        /** プロトコルとバージョン。例: 「HTTP/1.1」 */
        private String protocolVersion;

        /** リクエストURI。例: 「/a.cgi」 */
        private String requstPath;
        /** パラメータのMap。例: キー=「category」、値=「aaa」 (%エンコーディングはデコードしない)*/
        private Map<String, String> paramMap =  Collections.emptyMap();

        public String toString() {
            return ToStringBuilder.reflectionToString(this);
        }
        public RequestLine(String requstLine) {
            int length = requstLine.length();
            int cur, pos = 0;
            pos = requstLine.indexOf(' ');
            if (pos == -1) {
                method = requstLine;
                return;
            }
            method = requstLine.substring(0, pos);
            cur = pos + 1;
            pos = requstLine.indexOf(' ', cur);
            if (pos != -1) {
                requestUri = requstLine.substring(cur, pos);
                updatePathAndParam();
            } else {
                requestUri = requstLine.substring(cur, length);
                updatePathAndParam();
                return;
            }
            protocolVersion = requstLine.substring(pos + 1);
        }
        /** パスとパラメータを更新する 
         * @param max 
         * @param cur 
         * @param requstLine */
        private void updatePathAndParam() {
            int length = requestUri.length();
            int pos = requestUri.indexOf('?');
            if (pos == -1) {
                requstPath = requestUri;
                return;
            }
            this.paramMap = new TreeMap<String, String>();
            requstPath = requestUri.substring(0, pos);
            int cur = pos + 1;
            while (pos != -1) {
                String key = null;
                String value;
                char c = '\0';
                for (pos = cur; pos < length && (c = requestUri.charAt(pos)) != '=' && c  != '&';  pos++);
                if (c == '=') {
                    key = requestUri.substring(cur, pos);
                    cur = pos + 1;
                }
                pos = requestUri.indexOf('&', pos + 1);
                if (pos == -1) {
                    value = requestUri.substring(cur);
                } else {
                    value = requestUri.substring(cur, pos);
                    cur = pos + 1;
                }
                if (key == null) {
                    paramMap.put(value, null);
                } else {
                    paramMap.put(key, value);
                }
                
            }
        }

        public String getMethod() {
            return method;
        }
        public String getRequestUri() {
            return requestUri;
        }
        public String getProtocolVersion() {
            return protocolVersion;
        }
        public String getRequestPath() {
            return requstPath;
        }
        public Map<String, String> getParamMap() {
            return paramMap;
        }
    }
}


あまり効果がなくて止めたのが、「charAt()を何度も呼ぶ代わりにローカルのchar[]にコピーして配列アクセス」という処理。逆にまったく無意味なsubstring()とか入れるとたまに2割ぐらい早くなったりする。キャッシュメモリにどう乗っけるかみたいな話になるのかな。


パース処理じゃなくて、フィールドにセットするところをコメントアウトするととても速まったりする。これはセッターメソッド呼び出しや代入が遅いんじゃなくて、参照を保持する事でメモリが解放されない→余分にメモリ確保が必要、というようなメモリ確保のコストの問題のよう。JoSQLに渡すのがListじゃなくてIteratorだったらもう少し効率的にできるかも。


あと問い合わせに使わないフィールドは最初からパースしないように書式を指定してしまうのも有効(%Zとか実際に存在しないフォーマットを指定してもエラーにならないようにしてある。)