2010/10/31

java.util.ScannerクラスでCSVパーサを作ってみた Vol.3

今回は細かく処理内容を検証して処理構造そのものと、コード全体の見直しについてです。

入力文字列の処理機構

今回のパーサは次のような ENBFルールを満足するはずです。

line = word , { "," , word } ;
word = raw string | quoted string ;
raw string = { "x" } ;
quoted string =  """, { "x" | """" | "," } , """;
  • Note #1: ダブルクォートを2つ連結した文字は""","""と書かずに""""とする
  • Note #2: "x"は空文字列"", カンマ","、ダブルクォート"""を含まない1文字とする

言葉で書いても判りづらいところがあるので、図にすると次のような感じです。

EBNFルールの図

このルールでは空行はraw stringの繰り返し版という事になっています。 このため次のような各行がCSVとして認識することになります。

x
x,"x,"""

,

raw stringとquoted stringの認識について

今回は自分の決めたルールでカンマで区切って処理をするため、 word 単位で処理をすることができません。

そのため word 部分を切り出すために、次のようなルールで input を断片化し word を再構成しています。

  • ","でlineを raw stringquoted stringの断片 に分割する
  • 次のルールでraw stringを認識する
    • 条件0 isQuoteBlock==false かつ { "x" } ; で構成されている文字列
  • 次のルールでraw stringではないquoted stringの断片を認識します。
    • 条件1 完結したquoted string: isQuoteBlock==false かつ """, { """" | "x" } , """ ;
    • 条件2 quoted stringの開始要素: isQuoteBlock==false かつ """, { "x" | """" } ; isQuoteBlock = true.
    • 条件3 quoted stringの中間要素: isQuoteBlock==true かつ { "x" | """" } ;
    • 条件4 quoted stringの終了要素: isQuoteBlock==true かつ { "x" | """" }, """ ; isQuoteBlock = false.
  • 条件1であれば、そのままquoted stringと認識
  • 条件2であれば、続く条件3の要素を","を加えて連結して、最後に条件4を","を加えて連結してquoted stringと認識

面倒なquoted stringのところだけ図にすると次のようになります。

quoted stringを判別するルール

処理の構造ははっきりしたので、次に前回までで作成したコードの内容と少し比較してみることにします。

作成したコードの見直し

前回までで作成したCSVManagerクラスの動きをgraphvizで図にすると次のようになります。

前回作成したCSVManagerクラスの処理内容

さすがにこれは図にしても、まったく理解できそうにありません。

quoted stringの説明とリンクして要約すると次のような差がある事になります。

  • 条件1 """, { """" | "x" } , """ ; の判断は2つの条件に分割して処理している
    • isQuoteBlock==false かつ、先頭にダブルクォートを含み、
      • 先頭から最後まで偶数個のダブルクォートが連続する
      • または、先頭にダブルクォートを含み、途中"x"に相当する文字から最後まで奇数個のダブルクォートが連続する
  • 条件2 は isQuoteBlock==false かつ 先頭にダブルクォートを含み、条件1に該当しない場合
  • 条件3 は isQuoteBlock==true かつ 条件4に該当しない場合
  • 条件4 { "x" | """" }, """ ; の判断は2つに分割している
    • isQuoteBlock==true かつ、末尾にダブルクォートを含み、
      • 先頭から末尾まで奇数個のダブルクォートが連続する
      • または、任意の文字で始まり途中"x"に相当する文字から末尾まで奇数個のダブルクォートが連続する

どっちもどっちですが、ユニットテストを通すようにコードを修正したにしては、ちゃんと同じような動きをするコードになっています。

ところどころ、「先頭にダブルクォートを含み」、「末尾にダブルクォートを含み」のように、それをチェックしなくても続く条件に含まれているような冗長な判断はありますが、ちゃんとしているようです。

ただし、終了条件の中でlineがカンマである場合には無条件に空要素を加えなければいけないのに、tmpItemに要素が残っていない場合だけ判断されるようになっています。

問題のありそうなコード抜粋

        if (!tmpItem.isEmpty()) {
            row.add(tmpItem);
            System.out.println("isQuoteBlock:" + isQuoteBlock);
        } else if (line.endsWith(this.itemDelimiter)) {
            row.add("");
        }

tmpItemに何か値が入ったままになるような条件があれば、場合によっては空要素が正しく判断できないことになります。

しかし実際には、最初のEBNFルールを満足する場合、あいまいさはなく、この処理は不要です。 そのため最後でtmpItem変数に値が入っているような状況自体がエラーになります。

付け加えると、最後にisQuoteBlock変数がtrueであるような状況ももちろんエラーです。

何よりも、どのステップを処理しているのか分かりづらいのが致命的です。 ここら辺を修正しつつ、EBNFで記述した文法に対応するようなコードに修正していきます。

正規表現の見直し

それぞれの条件式をみると、同じEBNFルール({ "x" | """" })が確認できます。

この部分を正規表現にすると "^(\"\"|[^\"])*" となるので、この前後に"\""を加えるかどうかと、isQuoteBlock変数の条件を合わせるだけで、分割されたquoted stringの断片を区別する事が可能になります。

raw stringの正規表現はもっと短かく、 "^[^\"]*$" となります。

これを踏まえてCSVManagerクラスのgenRow(String line)メソッドを修正すると、次のようになりました。

変更を加えたコード

正規表現は効率を考えてインスタンス変数として準備してみました。

CSVManagerクラスのコンストラクタに追加したコード部分

+        c0Pattern = java.util.regex.Pattern.compile(String.format("^(%s%s|[^%s])*",
+                quoteString,quoteString,quoteString));
+        c1Pattern = java.util.regex.Pattern.compile(String.format("^%s(%s%s|[^%s])*%s",
+                quoteString,quoteString,quoteString,quoteString,quoteString));
+        c2Pattern = java.util.regex.Pattern.compile(String.format("^%s(%s%s|[^%s])*",
+                quoteString,quoteString,quoteString,quoteString));
+        c3Pattern = java.util.regex.Pattern.compile(String.format("^(%s%s|[^%s])*",
+                quoteString,quoteString,quoteString));
+        c4Pattern = java.util.regex.Pattern.compile(String.format("^(%s%s|[^%s])*%s",
+                quoteString,quoteString,quoteString,quoteString));

修正したCSVManager::genRow(String line)メソッド抜粋

    private Row genRow(String line) {
        if (line.isEmpty()) {
            return null;
        }
        // as example, line is "a,c,b".
        java.util.Scanner cScanner = new java.util.Scanner(line);
        cScanner.useDelimiter(this.itemDelimiter);
        CSVRow row = new CSVRow();

        if (line.startsWith(this.itemDelimiter)) {
            row.add("");
        }

        this.isQuoteBlock = false;
        String tmpItem = "";
        while (cScanner.hasNext()) {
            String item = cScanner.next(); // item is one of; "a c b"
            if (this.isQuoteBlock) {
                if (this.c4Pattern.matcher(item).matches()) {
                    // Condition#4
                    tmpItem += this.itemDelimiter + item;
                    row.add(tmpItem);
                    this.isQuoteBlock = false;
                    tmpItem = "";
                } else if (this.c3Pattern.matcher(item).matches()) {
                    // Condition#3
                    tmpItem += this.itemDelimiter + item;
                } else {
                    // error condition
                    System.err.println("error condition#1");
                }
            } else {
                if (this.c1Pattern.matcher(item).matches() || this.rsPattern.matcher(item).matches()) {
                    // Condition#0 or #1
                    row.add(item);
                } else if (this.c2Pattern.matcher(item).matches()) {
                    // Condition#2
                    this.isQuoteBlock = true;
                    tmpItem = item;
                } else {
                    // error condition
                    System.err.println("error condition#2");
                }
            }
        }

        // To salvage the item in progress which is placed at EOF.
        if (!tmpItem.isEmpty()) {
            // error condition
            System.err.println("error condition#3");
        } 
        if (line.endsWith(this.itemDelimiter)) {
            row.add("");
        }
        return (Row) row;
    }

エラーは通常行末までいかないと確実には分かりませんが、とりあえず異常な状態かどうかは、はっきりさせることができました。

とりあえず行ベースのCSVパーサについては、ここまでにして改行を含む、本格的なCSVパーサを作っていきます。

2010/10/30

java.util.ScannerクラスでCSVパーサを作ってみた Vol.2

行ベースの処理に限定したかいもなく、バグバグなCSVパーサを作ってしまいました。

今回はテストケースの作成と構造的なバグへの対応についてです。

プラットフォームについて

今回は NetBeans 6.9.1(Linux版)でJavaのバージョンをJava 5に設定してコードを書いていて、バージョン管理はSubversionで行なっています。

ユニットテストの実施は JUnit 4.xで行なっています。

OSは関係ないと思いますが、Ubuntu 10.04 LTSを使っています。

処理の概要

この部分は後から書いているのですが、文書で書くと何をいっているのかサッパリなので図を載せることにしました。

今回作成したパーサは改行で分割した文字列をカンマでさらに分割しています。

いくつかのパターンは次のようになります。

1行が複数の要素に分割される例

テストケースの検討

テストは総当たりで入力を与えて正しく出力するかを確認することにします。

今回のアプリケーションは規模は小さいですが、設計時点では状態遷移を定義していないので、内部処理は一つの巨大なメソッドで処理しており、内部構造のテストには向かないケースでしょう。

とりあえずjava.util.Scannerを使ってCSVファイルのパーサを作る事自体が目的なので、こういった背景は受け入れてテストケースを考えてみようと思います。

テストのポイント

処理をざっと眺めて内部状態が変化する値を中心に考えます。

今回の処理は区切り記号である ,(カンマ)"(ダブルクォート) によって状態が変化します。 内部では isQuoteBlock 変数一つだけを準備していますが、処理の流れは1行全体が "x", (isQuoteBlockの値は変化しない)の場合と "x,", (isQuoteBlockの値によるループと分岐処理)では大きく違います。

そこでクォート文字列の中でカンマが表われ、その前後の文字列にダブルクォートが含まれる場合に処理が正しく行なえるかを中心に考えていきます。

いくつのフィールドを並べるか

処理単位について考えると、 ダブルクォートで囲まれた範囲 を識別するための処理が複雑になっています。

例えば1行が """x""",y から「"x"」を取り出す処理(一語として"""x"""を処理)は "x,",y から「x,」を取り出す処理("xと"に分けて処理)や """x""," から「"x",」を取り出す処理(カンマ直前の""を無視する)などとの違いを考える必要があります。

その反面 x,x といったダブルクォートで囲まれていないカンマで区切られた要素は、isQuoteBlock変数がfalseである限りは、あっさりと処理を終えています。

ダブルクォートで囲まれていない要素をいくつ並べても意味はなさそうです。

またダブルクォートが閉じられた時点で値は返されてループは終了するため、外部からは1フィールドを取り出した時点で内部状態を表わすisQuoteBlock変数がfalseになっている事が確認できれば、次のダブルクォートから始まる文字列は正しく処理されることがわかります。

そこで複数のフィールドを含むのでななく、1つのダブルクォート文字列を評価してその直後のisQuoteBlock変数がfalseになっている事を確認することにします。

クォート文字列は何文字で作るのか

まずクォートで囲まれた要素として「適当な文字('x')」、「カンマ(',')」、「ダブルクォート('""')」、「空文字('')」が重複して並ぶとします。

'x'と''のポジションを入れ替える必要はないので、'x'と'""'、2文字の各要素の間にカンマが入る5文字も並べば良さそうだと考えたのですが、余裕をみて6文字を並べることにしました。

閑話休題 〜 テストケースを設計するために必要なこと

今回のプランは内部構造を考慮しているためホワイトボックステストもどきですが、どちらかといえばエラーケースを考えない境界値テストに近いと思います。

そもそもの設計がフィーリングなので厳密なホワイトボックスを実施するにはプログラマ(自分ですけれども)を越える理解が必要になるでしょう。 そんなのは現実的じゃないんですけどね。

こういった事から分かることは、ちゃんとした設計、特に 内部処理のアルゴリズム設計 は、テストを実施するチームが他にある場合は重要です。

外部設計をちゃんとやったものの、顧客には良くみえない、内部設計、プログラミングを簡略化すると陥りがちなのかもしれません。

特にエラーケースをエラーとして処理しなければいけないビジネスアプリケーションほど、重要だと思うんですけどね。 そういうビジネスアプリは顧客にパワーがないと重視されずにテストの段階で不具合が発覚するのかもしれません。

JUnitを使ってテストケースを生成する

具体的処理

入力用CSVファイルと期待する処理結果を格納した外部ファイルをTestCaseクラスのコンストラクタで作成します。

  • test/csv.autogen2/case1.csv - 入力に使うCSVファイル#01 (e.x. "x,""x,")
  • test/csv.autogen2/case1.ans - case1.csvと同時に作成する正解ファイル#01 (e.x. x,"x,)
  • ...
テストケースの作成

Netbeansで作成したテストケース

package org.yasundial.csvscanner.unittest;

import org.junit.After;
import org.junit.AfterClass;
import org.junit.Before;
import org.junit.BeforeClass;
import org.junit.Test;
import static org.junit.Assert.*;

import org.yasundial.csvscanner.CSVManager;
import org.yasundial.csvscanner.Row;

/**
 * $Id: AutoGenedUnitTestBlog.java 29 2010-10-29 02:21:42Z yasu $
 * @author Yasuhiro ABE <yasu@yasundial.org>
 */
public class AutoGenedUnitTestBlog {

    /**
     * A placeholder
     */
    CSVManager manager;
    /**
     * A words list for generating unquoted string.
     * <p>
     * e.x.) """x"",y"
     */
    String[] qwords = {"x", ",", "\"\"", ""};
    /**
     * A unquoted notation against each words words.
     */
    java.util.HashMap<String, String> unquoted;
    /**
     * A directory path which places all csv files.
     */
    String outputBasedir;
    /**
     * Prefix string for each csv file.
     */
    String csvPrefix;
    /**
     * A internal counter object which is used as a sequence number of output filename.
     */
    long loopCounter;

    /**
     * File Path Generator Utility Class
     * <p>
     * Its filename is generated based on given sequence number and prefix string.
     * It is also used for generating answer file object.
     * <p>
     * e.x.) (sequence, suffix) == (12, ".csv"),
     *     then generated path will be "test/csv.autogen/case12.csv"
     */
    private java.io.File getCSVFilepath(long sequence, String suffix) {
        return new java.io.File(outputBasedir + java.io.File.separator
                + csvPrefix + sequence + suffix);
    }

    public AutoGenedUnitTestBlog() {
        csvPrefix = "case";
        manager = null;
        // prepare the output directory
        outputBasedir = "test" + java.io.File.separator + "csv.autogen2";
        java.io.File tmpFileObj = new java.io.File(outputBasedir);
        if (!tmpFileObj.exists()) {
            tmpFileObj.mkdir();
        }

        unquoted = new java.util.HashMap<String, String>();
        unquoted.put("x", "x");
        unquoted.put(",", ",");
        unquoted.put("\"\"", "\"");
        unquoted.put("", "");

        loopCounter = 0;
        for (String i : qwords) {
            for (String j : qwords) {
                for (String k : qwords) {
                    for (String l : qwords) {
                        for (String m : qwords) {
                            for (String n : qwords) {
                                java.io.File csvFile;
                                java.io.File ansFile;
                                java.io.File ansFile1;
                                java.io.FileWriter writer = null;
                                try {
                                    csvFile = this.getCSVFilepath(loopCounter, ".csv");
                                    ansFile = this.getCSVFilepath(loopCounter, ".ans");

                                    // prepare the csv file
                                    writer = new java.io.FileWriter(csvFile);
                                    String record = "\"" + i + j + k + l + m + n + "\"";
                                    writer.write(record);
                                    writer.close();
                                    // prepare the answer file for first field
                                    writer = new java.io.FileWriter(ansFile);
                                    writer.write(unquoted.get(i) + unquoted.get(j) + unquoted.get(k)
                                            + unquoted.get(l) + unquoted.get(m) + unquoted.get(n));
                                    writer.close();
                                } catch (java.io.FileNotFoundException e) {
                                } catch (java.io.IOException e) {
                                } finally {
                                    try {
                                        if (writer != null) {
                                            writer.close();
                                            writer = null;
                                        }
                                    } catch (java.io.IOException e) {
                                    }
                                }
                                loopCounter++;
                            }
                        }
                    }
                }
            }
        }
        System.out.println("[info] loopCounter: "
                + loopCounter);
    }

    @BeforeClass
    public static void setUpClass() throws Exception {
    }

    @AfterClass
    public static void tearDownClass() throws Exception {
    }

    @Before
    public void setUp() {
    }

    @After
    public void tearDown() {
    }

    @Test
    public void letsTest() {
        // prepare the base dirctory
        for (long i = 0; i
                < loopCounter; i++) {
            java.io.File csvFile = this.getCSVFilepath(i, ".csv");
            assertTrue(csvFile.exists());

            // load first answer file
            java.io.File ansFile = this.getCSVFilepath(i, ".ans");
            assertTrue(ansFile.exists());

            java.io.BufferedReader reader = null;
            try {
                manager = new CSVManager(csvFile);
                assertTrue(manager.hasNext());
                Row row = manager.fetchRow();
                assertTrue(row.hasNext());

                // read first field.
                String input = row.next();
                assertFalse(row.hasNext());
                reader = new java.io.BufferedReader(new java.io.FileReader(ansFile));
                char[] readChars = new char[256];
                int readCharsLen = reader.read(readChars);
                reader.close();

                // compare first field
                String answer;
                // if the readCharsLen is -1, then the new String() method will be failed.
                if (readCharsLen == -1) {
                    answer = "";
                } else {
                    answer = new String(readChars, 0, readCharsLen);
                }
                assertEquals("[check answer #" + i + "]", input, answer);

                // check the post condition
                assertFalse(manager.isQuoteBlock());
                assertFalse(manager.hasNext());
            } catch (java.io.FileNotFoundException e) {
                assertTrue(false);
            } catch (java.io.IOException e) {
                assertTrue(false);
            } finally {
                if (manager != null) {
                    manager.close();
                    manager = null;
                }
                if (reader != null) {
                    try {
                        reader.close();
                        reader = null;
                    } catch (java.io.IOException e) {
                    }
                }
            }
            assertTrue(true);
        }
    }
}

前回作成したコードのバグについて

さて、テストケースを走らせると見事に完走しませんでした。

本質的に処理を間違えているところもあれば、条件式が意図した通りになっていないといった残念なバグがいろいろあります。

クォート文字列の終りを判断する正規表現の間違い

クォート文字列の終りを判断している個所は2個所あります。

まずはクォートで始まって、かつ終っている場合で、この場合はisQuoteBlock変数は変化しません。

修正後のコード1

             } else if (item.startsWith(this.quoteString)) {
-                if (item.endsWith(this.quoteString)) {
-                    // spacial case; the line is just a "a","b".
+                if (item.endsWith(this.quoteString)
+                        && (item.matches(String.format("^%s(%s%s)*%s$", // item is "" or """".
+                        this.quoteString, this.quoteString, this.quoteString, this.quoteString))
+                        || item.matches(String.format("^%s.*[^%s]+(%s%s)*%s$", // item is "x", """x" or "x""".
+                        this.quoteString, this.quoteString, this.quoteString, this.quoteString, this.quoteString)))) {
+                    // spacial case; the item is begin/end with '"', such as "a", "b""", "" or """"

次のケースは一旦isQuoteBlockがtrueになってから、クォート文字列の終りを判断している部分です。

ここは文字列の最後が奇数個のダブルクォートが連続しているかどうかで判断しています。

修正後のコード2

+                if (item.endsWith(this.quoteString)
+                        && (item.matches(String.format("^%s(%s%s)*$", // item is " or """
+                        this.quoteString, this.quoteString, this.quoteString))
+                        || item.matches(String.format("^.*[^%s]%s(%s%s)*$", // item is x" or x"""
+                        this.quoteString, this.quoteString, this.quoteString, this.quoteString)))) {
+                    // case: item must be b", b""", or ".

正規表現の文字列が固定な場合は、java.util.regex.Patternオブジェクトをインスタンス変数にしておくべきなんでしょうが、それはしていません。

最終的な形に落ち着くまでに、何回か正規表現の指定方法をミスしてしまい、たびたび意図せずにこの条件式の中に飛び込んでいきました。

int型カウンタのオーバフロー

これは本体に処理ではなくJUnitのコードに関連した問題です。

ループを複数回ネストしているため、当初int型で宣言していたもっとも内側をカウントするloopCounter変数がオーバーフローしました。

6文字までは問題なかったのですが、ループのネストを増やしたところ問題が発生したのでlong型に直しています。

close()しないオブジェクト達

これもループの数が増えたところで発覚したメモリリーク系の問題です。

テストケースではCSVManagerオブジェクトを生成したままにしていて、CSVManagerオブジェクトが内部で保持しているscannerオブジェクトについては何もしていませんでした。

CSVManagerクラスにclose()メソッドを追加して、内部ではscannerオブジェクトのclose()メソッドを呼ぶような代理処理を行なっています。

追加個所 - CSVManager::close()

   public void close() {
        if (scanner != null) {
            scanner.close();
            scanner = null;
        }
    }
内部状態のチェック

テストのためだけに内部状態が参照できるように変更を加えて、ユニットテストの中でダブルクォートの要素を読み取った後で値がfalseである事を確認しています。

追加個所 - CSVManager::isQuoteBlock

+    public boolean isQuoteBlock() {
+        return this.isQuoteBlock;
+    }
その他、細々としたこと

他のクラスで区切り文字をハードコードしていた部分は、変更可能なように修正しています。

Patternオブジェクトをあらかじめ準備しておくとか、修正するべきところはありそうですが、そこはおいおいやっていくことにしようと思います。

とりあえず、まとめ

動くものはできましたと。

それが正しく動きそうだということは分かっていますが、本当にどんな入力にも問題なく動くのかは、まだあやしいところがあります。

他のCSVパーサとの比較もまだ後にして、次回は全体の動きの見直しと細かい修正を行なっていく予定です。

2010/10/27

java.util.ScannerクラスでCSVパーサを作ってみた Vol.1

この記事の背景

IBMのdeveloperWorksはむかーしからよく読んでいますが、その中に「 今まで知らなかった 5 つの事項: 日常的なJava ツール」という記事がありました。

内容自体はとてもよくまとまっていたのですが、その中でCSVファイルや空白区切りのテキストファイルを対象としてjava.util.Scannerクラスに触れて、「ほとんどの(構文解析)ユーティリティーは機能過剰」、「String.split() では機能不足」な場合にScannerクラスが最適と述べられています。

java.util.Scannerを使いたい場面は「String::split()でも良いんだけど、数値はFloat, Integerと区別して読み込みたい」というシンプルな構文で、かつ型付けのあるケースだと思います。

あるいは「String::split()でも良いんだけど、1行の中に空白を区切りとしてどこかにある整数値を2つ読み込みたい」といった構文が破綻しているケースでしょう。

いずれにしてもString::split()の代りにScannerを使う事でコードがかなりすっきりすると思います。

動機

前置きが長くなりましたが、developerWorksの記事をみて「java.util.Scannerを使ってCSVのパーサを手で書こうとする人っているのだろうか」と思ったのがこの記事の動機です。

始める前から分かっていたのは、行単位で処理しないとドロ沼にはまる可能性が高いことです。

行単位で処理すれば字句解析器は単純で、構文解析もメモリに読み込まれた字句の分析だけで済むため、次の行を先読みする必要もありません。

もしまともに動くものが必要であれば、機能を減らす(制約を増やす)か、自作せずに十分にテストされたCSVパーサを使う事をお勧めします。

参考資料

CSVパーサの機能

CSVパーサは「Comma-Separated Values」の名前の通り、カンマで区切られたフォーマットの総称ですが、人によって、その意味するところには幅があります。

おおまかに「データの受け渡しはCSV形式でいいよね」という約束事をする場合、実際には次のような部分で混乱を招く事になります。

  • 1行1レコード? データに改行は入らないの?
  • 1レコードに入るデータの区切りはカンマ(,)? 空白( )でもいい?
  • 1フィールド(データ)は必ずダブルクォート(")で囲むの?
  • やっぱり微妙に形式が違うから、ruby1.9添付のcsv.rbで処理できる形式をCSV形式と呼ぼう ^^;

そこで具体的な例を使って、パーサの機能についてまとめていきます。

CSVパーサの処理ルール

ルールを列挙して、正しい例を挙げていきますが、分かりやすくするために必要なら不正な例も挙げることにします。

区切り文字はカンマとして、必ずしもダブルクォートで囲む必要はない
1,2
"3","4"

これは下記の形式と同じです。

1,2
3,4
文字としてダブルクォート、カンマ、改行を含む場合には必ずダブルクォートで囲む

次に挙げた例の中で、1つめのレコードはaaとb"cという2つのフィールドを持ちます。 2つめのレコードはX,Xというカンマを含むフィールドと'Y
Y'という改行を含めたフィールドの2つから成ります。

aa,"b""c"
"X,X","Y
Y"
ダブルクォートを文字として含める場合には、ダブルクォートを一つ置いてエスケープする

既に前の例で使っていますが、文字としてダブルクォートを使う場合にはフィールド全体をダブルクォートで囲った上で、ダブルクォートを2つ続けて書く事で、ダブルクォート1文字を表現します。

このルールを加えると、次のような一つのフィードを作る事もできます。

"aa,""b""""c""
""X,X"",""Y
Y"""
カンマで始まる行は空の文字列を直前に含んでいる、カンマだけで終る行は空の文字列を改行前に含んでいる

次の例は2つの空の文字列からなるレコードの例です。

,

カンマで終わる行は RFC4180の要件からは外れるので、次の形式を推奨します。

"",""

このため空の文字列を表わす方法としては、ダブルクォートで囲むのが推奨する方法です。

文字コードや改行は厳密には定義しない。でもUTF-8が基本

文字コードや改行記号については厳密に決めません。処理系に依存させています。

手元の環境はlinuxなので、改行記号は'\n'、文字コードはUTF-8で処理をしています。

CSVパーサが想定する処理方法

ファイル形式に含まれませんが、パーサをどう呼び出すのかは決めておかないと混乱しそうです。 ここは通常のIteratorクラスやjava.util.Scannerをお手本にして、次のようなレコード(Row)単位での処理を考えています。

処理概要の疑似コード

今回は次のようにファイルを入力として、レコード単位で処理するケースを想定しています。

今回想定するコード例

...
    CSVScanner scanner = null;
    scanner = new CSVScanner(input_file);

    while (scanner.hasNext()) {
        Row row = scanner.fetchRow();
        while (row.hasNext()) {
            String field = row.next();
        }
    }
...

いよいよ文字として改行を許さない場合に限定したCSVパーサを作ってみる

普通に行ベースで処理をすると幸せになれそうなのは分かっているので、まずは改行を文字として含まないケースで考えてみます。

java.util.Scannerでは"\n|,"のような正規表現で区切っていく事も可能ですが、直前にマッチした区切り文字が'\n'なのか','なのかを知ることはできません。この点が状態遷移する処理との組み合せで扱いずらいところです。

処理概要

まずは改行記号を使ってfetchRow()メッドの中でレコード(行)単位に分解します。

次にprivateメソッドでレコードをさらにフィールド(要素、データ)単位に分割していきます。

このために2種類のScannerオブジェクトを準備して、片方はファイル全体を行毎に分割するscannerオブジェクトで、もう一方は使い捨ての各レコードをカンマで分割するcScannerオブジェクトです。

ダブルクォートで囲まれている間はカンマはそのまま出力するため、フラグとしてisQuoteBlock変数を使います。

だいたい、こんなところで処理の流れについての説明は終りです。

java.util.Scannerクラス固有の考慮点

空の文字列をどのように扱うかがポイントになります。

例えば ,,,X ようにカンマが連続する入力を考えた場合に、今回は "","","",X と同じだとしているので、カンマを区切りとして3つの空要素と文字"X"の3つを認識する必要があります。

しかしjava.util.Scannerクラスは最初の空要素を飛して、Xの前に2つの空要素があるという処理をします。 同様に最後に空要素があった場合も飛ばしてしまいます。

次のようなサンプルコード確認できます。

TestScanner.javaファイル - java.util.Scannerクラスの特徴的な動き

import java.util.Scanner;

public class TestScanner {
  public static void main(String[] args) {
    Scanner s = new Scanner(",,,X");
    s.useDelimiter(",");

    int i=0;
    while(s.hasNext()) {
      String item = s.next();
      System.out.println(i + ": " + item);
      i++;
    }
  }
}

これを実行すると次のような出力が得られます。

0: 
1: 
2: X

連続したカンマの間にある空要素は、空文字列と同値ですが、これは直感的に理解できるので特に混乱することはないでしょう。

作成したファイル群

とりあえず説明が必要なのかどうか、作成したファイル一式を載せてみます。

Mainクラス

Main.javaファイル

/**
 * This class is a sample implementation of the main process.
 * @version 1.0
 * @author Yasuhiro ABE <yasu@yasundial.org>
 */
public class Main {

    /**
     * This main method is just a sample implementation.
     * @param args the command line arguments, currently it's not used.
     */
    public static void main(String[] args) {
        java.io.File f = new java.io.File("src/test.csv");
        if (!f.exists() && args.length > 0) {
            f = new java.io.File(args[0]);
        }
        CSVManager myscanner = null;
        try {
            myscanner = new CSVManager(f);
        } catch (java.io.FileNotFoundException e) {
            System.err.println("The given CSV file couldn't be found or couldn't be read.");
            System.exit(1);
        }

        int i = 0;
        while (myscanner.hasNext()) {
            int j = 0;
            Row row = myscanner.fetchRow();
            if (row == null) {
                System.out.println("!! The row is just new line, then skipped. !!");
                continue;
            }
            while (row.hasNext()) {
                String item = row.next();
                System.out.println(Integer.toString(i) + ", " + j + ": " + item);
                j++;
            }
            i++;
        }
    }
}
CSVManagerクラス

CSVManager.javaファイル

/**
 * It's an experimental CSV parser using java.util.Scanner.
 *
 * <p>
 * <em>There are some important assumptions;</em>
 * <ul>
 *   <li>Each field doesn't contain the new line character except the end-of-line,
 *     even though the item is quoted by the double-quote.</li>
 *   <li>Each field will no be sanitized, it means you can input the incorrect CSV file,
 *     but the result is not defined.</li>
 * </ul>
 *
 * <p>
 * If you have a simply structured text file and each variable has a type, such as integer and String,
 * then you can use java.util.Scanner class.
 *
 * <p>
 * If you just want to use java.util.Scanner class for a CSV file and which may be a full featured CSV,
 * you should change your mind, I strongly recommended.
 *
 * @version 1.0
 * @author Yasuhiro ABE <yasu@yasundial.org>
 * @see <a href="http://download.oracle.com/javase/6/docs/api/java/util/Scanner.html">java.util.Scanner</a>
 */
public class CSVManager {

    private String lineDelimiter;
    private String itemDelimiter;
    private String quoteString;
    private java.util.Scanner scanner;
    private boolean isQuoteBlock;
    private java.io.File csvFile;

    /**
     * @param file a CSV file.
     * @throws java.io.FileNotFoundException
     */
    public CSVManager(java.io.File file) throws java.io.FileNotFoundException {
        csvFile = file;
        lineDelimiter = "\n";
        itemDelimiter = ",";
        quoteString = "\"";
        scanner = null;
        isQuoteBlock = false;

        this.scanner = new java.util.Scanner(csvFile);
        this.scanner.useDelimiter(this.lineDelimiter);
    }

    /**
     * The default delimiter is ",", so you can change that by this method.
     * @param itemDelimiter A delimiter string, like ','.
     */
    public void changeDelimiter(String itemDelimiter) {
        this.itemDelimiter = itemDelimiter;
    }

    /**
     * A user use this method to get a row representation object.
     *
     * @return Row A user will use this interface object.
     * If the row is empty, then the return value will be null.
     */
    public Row fetchRow() {
        if (this.scanner.hasNext()) {
            String rawRow = this.scanner.next();
            return genRow(rawRow);
        }
        return null;
    }

    /**
     * It's a wrapper method for java.util.Scanner::hasNext();
     * @return boolean as same as java.util.Scanner::hasNext();
     */
    public boolean hasNext() {
        return this.scanner.hasNext();
    }

    /**
     * This method prepare the Row object from the given line (row) string.
     * @param line A csv row string, such as "a,b,c."
     * @return Row object or null if the given line is empty.
     */
    private Row genRow(String line) {
        if (line.isEmpty()) {
            return null;
        }
        // as example, line is "a,c,b".
        java.util.Scanner cScanner = new java.util.Scanner(line);
        cScanner.useDelimiter(this.itemDelimiter);
        CSVRow row = new CSVRow();

        if (line.startsWith(this.itemDelimiter)) {
            row.add("");
        }

        String tmpItem = "";
        while (cScanner.hasNext()) {
            String item = cScanner.next(); // item is one of; "a c b"
            if (this.isQuoteBlock) {
                if (item.endsWith(this.quoteString)) {
                    // case: b"
                    tmpItem += this.itemDelimiter + item;

                    row.add(tmpItem);
                    this.isQuoteBlock = false;
                    tmpItem = "";
                } else {
                    // case: c
                    tmpItem += this.itemDelimiter + item;
                }
            } else if (item.startsWith(this.quoteString)) {
                if (item.endsWith(this.quoteString)) {
                    // spacial case; the line is just a "a","b".
                    row.add(item);
                } else {
                    // case: "a
                    this.isQuoteBlock = true;
                    tmpItem = item;
                }
            } else {
                // special case; the item is not quoted like 123.
                row.add(item);
            }
        }

        // To salvage the item in progress which is placed at EOF.
        if (!tmpItem.isEmpty()) {
            row.add(tmpItem);
        } else if (line.endsWith(this.itemDelimiter)) {
            row.add("");
        }
        return (Row) row;
    }
}
Rowインタフェース

Row.javaファイル

/**
 * This interface provides a record representation of a CSV file.
 *
 * If you want to use the different implementation of CSVRow class, 
 * such as using java.util.ArrayList, then implement this interface in your own CSVRow class.
 *
 * @version 1.0
 * @author Yasuhiro ABE <yasu@yasundial.org>
 */
public interface Row {

    /**
     * <pre>
     * It will be used like that;
     *   while(row.hasNext()) {
     *     String item = row.next();
     *     ...
     *   }
     * </pre>
     * @return boolean If you will be able to call next(), then true.
     */
    public boolean hasNext();

    /**
     * It return a each separated field item as a String object.
     * @return String A column field item.
     */
    public String next();
}
CSVRowクラス

CSVRow.java

/**
 * A sample Row implementation class.
 *
 * @version 1.0
 * @author Yasuhiro ABE <yasu@yasundial.org>
 * @see Row
 */
public class CSVRow implements Row {

    private java.util.LinkedList<String> itemList;
    public CSVRow() {
        itemList = new java.util.LinkedList<String>();
    }

    public void add(String field) {
        itemList.push(field.replaceAll("^\"", "").replaceAll("\"$", "").replaceAll("\"\"", "\""));
    }

    public boolean hasNext() {
        if (itemList.size() > 0) {
            return true;
        }
        return false;
    }

    public String next() {
        return itemList.pollLast();
    }
}

とりあえず

これがちゃんと動くものなのかは、テストケースを準備する必要があるので、とりあえずここまでにして次にテストの方法をまとめていきます。

pythonかrubyのCSVパーサとJavaのCSVパーサを何か使って、比較もしてみる予定です。

2010/10/28追記: テストケースをいろいろ準備して試したところ、バグバグなのが分かったので次の記事でテストケースの作成方法と修正したコードについて載せる予定です。 エラーの処理をちゃんとするのは面倒なので、本気でCSVのパーサが必要な場合は行ベースで処理をせずに一文字づつ読み込みながら状態遷移するオートマトンを設計してください。

2010/10/25

NetBeans 6.9.1でスペルチェックが動き出さなかった理由

Ubuntu 10.04 LTSでNetBeans 6.9.1で作っていたプログラムのJavadocコメントを似非英語で書いていたら、スペルチェックが起動していないのに気がつかずに大量のスペルミスを残していました。

スペルチェックは標準プラグインに含まれているので、何も考えなくても良いはずだと思っていたのですが、日本語環境だと日本語をスペルチェックしようとするようです。まぁ当然といえば、当然の動きですよね。

そもそも言語がまったく違うので、アルファベットなら問答無用に英語のスペルチェックが動いてくれた方が良いんですけれど、ISO-8859-1なんかの欧州圏ではまぎらわし動きをするんでしょうし、まぁ物事は自分に都合良くはいきません。

さて、オプション画面を眺めていたらデフォルトロケールに ja_JP があったので、辞書に合せて en_US に修正して無事にスペルチェックが動きました。

NetBeansでスペルチェック関連のオプションを設定しているところ

これで恥しい間違いを残したままコードを公開する必要はなくなったのですが、英語の内容やコードそのものを公開する方がもっと恥しいことになるんですよね…。

2010/10/19

情報カードに合ったボールペンの選択

今回は技術的な話しではなくて、普段持ち歩いているメモ帳の紙質とボールペンとの相性のお話しです。

アイデアなんかを思いついたときにメモを取る道具として、 コレクトライフといったメーカの5x3サイズの情報カードを 専用ケース(CP-453)に入れて持ち歩いています。

コレクトのWebサイトではCP-453が載っていませんが、下敷付きのカードケースでメモを取るにはお勧めです。 というか、これ以外には考えられません。

コレクトの用紙はかなり固くしっかりして好きなのですが、カードケースの固めの下敷との相性もあるのか、ボールペンの種類によってはうまく書けないという状態になりました。

症状/課題点

固めの紙にうまくインクが吸い込まないようで、あきらめていたのですが、情報カードを変えて書き比べてみたら、どうやら コレクト製の情報カードSigno 0.28mmの組み合せの時だけ、特に悪いという事がわかりました。

普通の紙であればSignoは書き味も良くて好きなのですが、今回はその書き味を実現するためのインクが紙の上で固まってしまったようです。

普段、PCや机に向っている時にはA4サイズのコピー用紙(裏)とJetStreamの3色ボールペン(0.7mm)を使っているのですが、情報カードケースにはうまく入らず、また太さも適当ではないため他のものを選ぶ事にしました。

そこでいくつか軸の細いボールペン(油性、ゲルタイプ)を準備して書き比べてみる事にしました。

候補にするボールペン

もともと文房具は好きなので、ボールペンもいくつか持っていました。 それに新しく購入したものも含めて書き味とインクの乾き具合を比較してみることにします。

今回試したボールペン(メーカ、品名、型番)は次の通りです。

  • uni, Signo 0.28mm, UM-151-28 (比較用)
  • uni, Signo RT 0.38mm, UMN-103
  • uni, JetStream3 3色ボールペン 0.7mm, SXE3-400-07
  • uni, JetStream 0.5mm, SXN-150-05
  • ZEBRA, HYPER JELL 0.5mm, JJ101
  • ZEBRA, エアーフィット500 0.5mm, BA20
  • Pentel, Slicci 0.3mm, BG203-F1
  • Pentel, Slicci 0.4mm, BG204-A
  • Pentel, ENERGEL 0.5mm, BLN75-A
  • 他、多数

これらのペンを使って情報カードの上に1行1ペンを基本に、文字を書いて、しばらく経ってからまとめてゆびでなぞってみたりしました。

今回は5x3という比較的狭い領域に文字を書く必要があり、A4程度のメモ用紙やノートを使った場合とは違って、固めの下敷と紙質の上で書くという特殊な事情があります。

評価とその基準

今回の評価の際に確認したのは次の点です。

  • 書いてから5分程度後に指でなぞって滲まないこと
  • 書き始めからなめらかに書けて、かすれないこと
  • 「影響」といった画数の多い漢字を書いて、判読できること
  • 全体的な書き味やペンの持ち具合が気に入ること

普段は問題なく使えるJetStream 0.5mmはカードケースの上では力が入らずにかすれてしまう場面がありました。

JetStream 0.7mmは軸の太さもあってお気に入りですが、やはり線が太すぎるので、今回の用途には向きません。

その他にはSlicciと対抗すると思われるHi-TEC-Cなどが含まれていますが、あまりにも書けなくなるタイミングが多いので使えない事を確認して終りました。

結局のところ書き始めから、ほぼ問題なく書けたのは数年前に購入して放っておいたSlicci 0.3mmだけでした。 若い子向けのファンシーな色が揃っているペンですが、速乾性となめらかさに関しては最高だと思います。 もちろん文字の線も細いですし。

Slicciの問題はペン軸が細すぎて、長時間持つには疲れるということです。 ENERGELは持ちやすさや速乾性がウリでSlicciの課題を克服できるかと期待したのですが、今回のケースではインクが出すぎて線の太さや速乾性について最悪な結果になってしまいました。

重ねていいますが、A4程度のサイズで普通の紙質のメモ用紙に、しっかりと力を入れて書く場合は、こんな結果にはならないでしょう。Signoはとっても良い書き味を持っていますし、いまでも良く使う好きなペンの一つです。

今回の結果を受けてオレンジ色しかなかったSlicci 0.3mmでは、ちょっとアレなので、近所の街の文房具屋さんでSlicci 0.3mmのブラックとブルーブラックを購入してきました。

ブルーブラックは良い発色で、とても気に入っています。

さいごに

最近はダイソーでも5x3サイズの情報カードを100枚100円ぐらいで扱っています。

これの紙質はペナペナしていて、気に入りませんでした。

CRCカードの練習用とかの目的で、ダイソーの5x3サイズも持ってますけどお勧めはできないです。

ダイソーで買うなら、紙質を考えると、白地ではなくて色付きの5x3サイズのカード(80枚100円)か、文房具コーナではなくてラッピングコーナ付近にある名刺サイズの無地メッセージカード(100(or 50?)枚100円程度)がお勧めです。

2010/10/21追記: 名刺サイズの無地メッセージカードはダイソーで50枚100円でした。 コレクトの100枚200円程度のものよりもずっと厚いので、まぁ値段相応かなと思います。

いまのところはメモを取る名刺、5x3サイズ、考えをまとめるB6サイズ、の併用がベストかなと思っています。 さすがにB6サイズは持ち歩けないので、めったに使いませんけどね。

この記事で取り上げた品々

2010/10/11

IPv6は脆弱なのか

プライバシーに対する懸念をやたら増幅して活動する人たちがいますが、それでもプライバシーは重要だと思います。

ただプライバシーは企業活動上の危機管理などと直交する問題を含んでいるため、一般的な仕様に含まれると少し面倒なことになりそうです。

プライバシーの懸念

IPv6はIPv4にはない機能が実装されています。 特にネットワークに簡単に接続できるように、IPv4では後付けだった仕組みが、IPv6では標準仕様に含まれています。

現状(IPv4)との違いについてセキュリティの観点から「 情報セキュリティ技術動向調査(2009 年下期) 4.IPv6セキュリティ」にまとめられています。

この中でプライバシーについて RFC3041RFC4941に触れて、MACアドレスを元にIPアドレスを設定した場合にネットワークを変えても機器が特定される可能性、解決策と関連する課題についてまとめられています。

IPv6アドレスのバーゲンセール

プライバシーの懸念が広がっていくと、より多くのランダム化されたIPv6アドレスを端末機器が要求し、湯水のようにIPv6アドレスが単一の物理ポートに割り当てられる時代がくるかもしれません。

つまりDHCPやらRAやらが割り当てた以外のアドレスをエンドユーザが要求するということですが、FirewallやらN-IDSやらの通信記録を元に端末を特定したいニーズは危機管理上、確実に存在しています。

機器毎にユニークなMACアドレスランダム化されたり、されなかったりするIPv6アドレス との対応を記録する仕組みは今でも必要ですが、今後はより重要になりそうな気がしています。

それもよく管理されている場所ではなくて、不特定多数が利用するような場所においてより必要に思えます。

保険としてのログの重要性

ネットカフェやらネットワーク管理者が存在しない中小企業など、ランダム化されたIPv6アドレスを使われたくないと思っていても、それは無理でしょう。

MACアドレスベースのフィルタをかけているつもりでも、元々IPv6は通信を許す方向で設計されていますから、DHCPからの割り当てが行なわれないだけでRAベースでは重複しないアドレスを割り当てるかもしれません。

何はなくともログを記録して残す仕組みがブロードバンドルータやら、ネットワーク機器ではより重要になりそうに思えます。

IPv6にするとより安全なのか

IPAの文書では総当たりのネットワーク探索や機器の特定といった点についてIPv4ネットワークとの違いに触れられていますが、現状よりも時間や投資のコストが違うというだけで、エンドユーザが考慮しなければいけない(プライバシーや侵入といった)脅威のレベルに違いはないと感じました。

現状の課題はIPv6でも引き続き

IPv6では機能が増える分だけ安全ではないようにみえますが、現状でもMACアドレスを詐称することが可能ですから、できることはいろいろあります。

ネットワークに接続する端末の選択

不正なブロードキャスト通信やMACアドレスの詐称に対応するためには、IEEE 802.1x認証だけが現状の解決策ではないでしょうか。

これからはMACアドレスフィルタと組み合せて、IEEE 802.1x認証を、ID/Passwordをキーボードから入力するという以外の、より一般的な形で普及することが必要そうに思えます。

例えばコーヒーメーカーをネットワークに接続する時の手順はどうなるでしょう。

  • 手元のAndroidやiPhone端末にコーヒーメーカーの管理アプリをダウンロードする
  • コーヒーメーカーと手元のスマートフォンをbluetoothで接続する
  • 設置責任者が持つ認証に必要な情報(ID/Password)を入力する
  • 構内WiFi網を経由して管理アプリは、コーヒーメーカーのMACアドレスをサーバに登録する
  • コーヒーメーカーをLANに接続する

bluetoothだけで良いんじゃない 、というのは置いておいて、このケースだとID/Passwordに相当する情報の漏洩が心配です。

その作業を行なう人と情報の組み合せが一致している事が必要です。

それなら接続に必要な情報は文字列としてのID/Passwordである必要ないわけで、スマートフォンに登録してボタン一つで送信すれば良いんじゃないかという感じがします。

漏洩を考えると公開鍵暗号の仕組みを取り入れるのが一番でしょうね。それも使い易い形で。

そもそもLAN内部からどうやってこのコーヒーメーカーサーバに接続するのかを考えれば、LAN内部のアプリケーションレベルでは サービスの発見能力 が問われそうです。

IPv6の長いアドレスを直接入力するような状況は避けたいですが、それを全部DNSで管理するというのは情報の信頼性を下げてしまいそうで、間違ったアプローチのように思えるからです。

さいごに

MACアドレスについては、下位層の話しですし、あまり議論されないんですよね。

現状のIPv4ネットワークでは機器を特定するためにMACアドレスを利用していたり、DHCPがIPを割り振る際に登録された機器かどうかみていたりします。

でもMACアドレスの偽造は簡単だし、ノートPCの裏側などに印字されている場合も多くて、産業スパイ等外部からその企業内部の秘密を守るというよりは、 社員がプライベートなPCを持ち込む事を防ぐ程度の役割 しかありません。

IPv6になっても、そういう状況に変化はないわけで、IPv4が枯渇するという理由以外にIPv6を導入する理由をみつけるのは難しいでしょうね。

それでもNATを使わずに機器が直接(Firewallを経由して)、外部のサーバと通信をするのは本格的でワクワクすると思うのは私だけでしょうか。

2010/10/10

Ubuntu 10.04 LTSでレンタルサーバの確認用にVirtualHostを設定してみる

外部のレンタルサーバ上のWebサーバにあるコンテンツ確認を手元のUbuntu 10.04 LTSで行なっていますが、できるだけ外部サイトと同じ環境を準備する必要があるなぁと思っています。

特に手元で http://localhost/ host1 /index.html といったURLでみたファイルを、外部サイトにコピーして http://host1.example.org/index.html のようなURLで正確に見れるかを確認する時には、だいたいは相対パスで対応できるのですが、十分ではない時がまれにあります。

サイト内の参照を相対パスで記述することはできますが、絶対パスで参照したいものもあります

favicon.icoは/直下に必要ですし、各ページで共通のテンプレートを使いたい場合に相対パスは手間で、サイト全体のロゴは/images/logo.pngのようなパスで全ページに埋め込むのがページの階層によって相対パスを作成するより自然に思えます。

ホスト名を追加する

とりあえず手元で確認するために、/etc/hostsを編集して手元で適当なホスト名をでっち上げることにします。

この時には存在しないホスト名(今回は stage2 )をループバックアドレス(127.0.0.1)に指定します。

/etc/hostsファイルの127.0.0.1で始まる行を編集

127.0.0.1 localhost stage2

もしDNSがあるなら、/etc/hostsではなくてDNSサーバにレコードを追加しても良いですが、他のサーバからアクセスするのでなければ勿体をつけた方法かなと思います。

Apacheに新しいホスト名(stage2)を認識させる

ホスト名として何でもありの'*'を対象にしたVirtualHostの設定は既に入っています。 しかし複数のVirtualHost設定があった場合には、より細かくホスト名を指定した設定が優先されます。

今回はdebianの流儀に従って、まずは新規に設定ファイルを /etc/apache2/sites-available/stage2 に準備します。

新しく作成したstage2ファイルの内容

<VirtualHost stage2:80>
        ## global settings
	ServerAdmin webmaster@example.org
	DocumentRoot /var/www/stage2
	<Directory />
		Options FollowSymLinks
		AllowOverride None
		Order deny,allow
		Deny from all
		Allow from localhost
	</Directory>
        ## log settings
	ErrorLog /var/log/apache2/stage2.error.log
	LogLevel warn
	CustomLog /var/log/apache2/stage2.access.log combined
</VirtualHost>
設定ファイル上で変更するべきところ

DocumentRoot行と、その他の stage2 と書かれているところを適宜ホスト名に。

接続元はlocalhostだけから可能にしましたが、Windowsなどのホスト上でVMWareやVirtualBoxを動かしていて、Windows上のブラウザからアクセスしたい場合には、Allowで始まる行に適当なIPアドレスを指定してあげる必要があります。

外部からアクセスされても困らなければ、面倒を避けるために Allow from all にした方が良いかもしれません。

プライバシーやらと利便性は直交する場合が多いんですよね。他に良い方法があるのかな…。

/etc/apache2/sites-available/以下の設定を有効にする

作成したファイル名をキーにして、設定を有効にします。

$ sudo /usr/sbin/a2ensite stage2
$ sudo /etc/init.d/apache2 restart

コマンドを実行すると設定ファイルが /etc/apache2/sites-enabled/ 以下からリンクが張られ、リスタートする事で読み込まれて有効になります。

こういうスクリプトは設定を入れ替えたり、部分的に切り替えるには便利ですが、ファイルを作成するツールはないんですよね。

スクリプト化の利点はパスを把握する必要がないっていうところもあるかと思うのですが、 /etc/apache2/sites-available/とパスが判っていれば、 ln -s コマンドでも同じことができてしまいますし、こっちの方が確実そうに思えます。

まぁあらかじめファイルを準備しておく用途では問題ないんですけど、全工程を自動化するにはちょっと足りない印象があります。

Webブラウザから確認

Ubuntu上のGoogle Chromeで確認してみます。

$ google-chrome http://stage2/

さいごに

ログファイルを新しく作成したので、定期的に削除するのを忘れていましたが、/etc/logrotate.d/apache2 をみるとファイル名が .logで終っているファイル は自動的にローティションの対象になっていました。

/etc/logrotate.d/以下のファイルをみると、サブディレクトリ以下のファイルでもファイル名を指定しているものも珍しくはないので、本格的に運用をする場合にはログ周りの設定をちゃんとみることは重要です。

でもなぁ、適当なIPアドレスをばらまくような設定をしている施設もあるみたいだし、ログファイルなんてデフォルト設定のままにしているところもあるだろうなぁ。

"rotate 52"とか"rotate 4"とかの設定がバラバラになっている状態をみると、適切な値に保つ事は結構面倒なことなのかもしれません。

「syslogは管理者のもの、アプリは使うべからず」なんて場合もあるでしょうから。syslogがそんな切り分けをちゃんとやってくれれば良いんですよね…。できるかな…、できるか…。

2010/10/05

ActivePerlスクリプトをWindowsの「送る」メニューに加えてみた

Windows Vista以降だと簡単にExplorerからShift+右クリックで開くメニューに「パス名でコピー」が表示されますが、Windows XPではその機能がないようです。

カスタマイズをサポートするアプリケーションを使う事で加えることができるようだったのですが、汎用性がないので任意のスクリプトを右クリックメニューに加える方法を調べてみました。

Explorerの右クリックメニューに項目を加える

ユーティリティを使わずにコンテキストメニューに項目を加える方法について、ざっとみたところではDLLを HKEY_CLASSES_ROOT¥*¥shellex¥ContextMenuHandlers 当りに加えてあげるのが最近のやり方のようでした。

任意のメニュー項目を追加する事自体は可能

いろいろ調べてみると、かなり以前に 任意のメニュー項目を作成する方法をまとめたページがみつかりました。

Windows XPでもレジストリの HKEY_CLASSES_ROOT\*\shell\CopyPathname\command ("CopyPathname"は任意)というエントリを作成して、commandの値に C:\usr\bin\copypath.bat %1 にように適当なアプリケーションを指定するとメニューから、そのファイル名を引数としてアプリケーションを起動できることは確認しました。

今回採用した方法

レジストリをいろいろ編集するのは良さそうには思えなかったので、今回はより安全で簡単な方法として「送る(N)」(SendTo)メニューの中にショートカットを加える方法を使いました。

ActivePerlで作るパス名をクリップボードにコピーするスクリプト

これはActiveStateの Win32::Clipboardのサンプルコードそのままです。

第一引数の文字列をクリップボードにコピーするActivePerlスクリプト: C:\usr\bin\pathcp.pl

use Win32::Clipboard;
my $clip = Win32::Clipboard("$ARGV[0]")
さらにバッチファイルの作成

ファイルハンドラを追加しても直接「送る」(SendTo)メニューの中に配置する事はできなかったので、バッチファイルを作成しました。

pathcp.plを呼び出すためだけのバッチファイル: C:\usr\bin\pathcp.bat

"C:\usr\bin\pathcp.pl" "%1"

このpathcp.batファイルのショートカットをSendToフォルダに置けば完了です。

SendToフォルダは Documents and Settings の下にありますが、「ファイル名を指定して実行」から sendto と入力してもフォルダが直接開くので、そこにショートカットを置くのが楽そうです。

JScriptでパス名をクリップボードにコピーするスクリプトを作成みてみる

さすがにActivePerlが入っている環境はそうないと思うので、一般的なJScriptで作成してみました。

Googleで検索して参考にしたのは次のページです。→ 「 JScriptでファイルシステムとクリップボードにアクセス

JScript版のpathcp.jsスクリプト

var ie = new ActiveXObject('InternetExplorer.Application');
ie.Navigate('about:blank');
while(ie.Busy ) {
    WScript.Sleep(200);
}
var cb = ie.Document.parentWindow.clipboardData;
if (WScript.Arguments.length > 0) {
  cb.setData('Text', WScript.Arguments(0));
}

内部でIEを呼び出しているため確認のダイアログが出たり、セキュリティ設定のレベルによってうまく動かないこともあると思います。

手元の環境では画面の最背面にダイアログが表示されてみえい事で気がつかず、うまく実行することができない場合もありました。

ActivePerlは1x1ピクセルのウィンドウを開いてクリップボードにアクセスするための機能を持たせているので、IEを経由するよりも負荷は低そうです。

さいごに

コンテキストメニューの中をいろいろ編集できると良さそうですが、通常はDLLを使うようですね。 MSDNに関連する記事がありでしたが、そこまでやるなら適当なアプリケーションを入れたほうがよさそうです。

クリップボードにアクセスする機能はActivePerlが良さそうでした。 ただこれが使えるような環境は恵まれているのかもしれません。

今回の操作だけではコストに見合わない感じですが、操作手順を渡す時にアプリケーションからファイルを選択するよりも、コンテキストメニューからアプリケーションを選択させる方が誤操作の防止には役立ちそうです。

2010/10/04

名刺サイズのGuitar Chords Cheat Sheet

インクジェットプリンターは 名刺サイズのカラー印刷 が簡単にできるので、 数年前にCanon PIXUS iP4300を購入してから家のネットワーク構成図とか主要な設定パラメータなどいろいろな資料を印刷してきました。

今回はギターのコードを分析するためにルート音からの相対的な指板上の各ポジションの一覧を作成してみました。

作成した図一覧

Root 6th Chord Position Table
Root 5th Chord Position Table
次の3番目の表は5弦3フレット、C音をルートとしてみた図になっています。 2番目の情報と被っているんですけどね、うまく並べると12フレットまでのスケールがわかります。
Root 5th Chord Position (Nut) Table
Root 4th Chord Position Table

印刷用PDFファイル

Webに載せるために150%のサイズでJPEGファイルを作りましたが表示専用で、印刷用としてはファイル形式も中身も適さないんですよね。 今回はMacで作業をしたので、プリンタメニューから PDFファイルに出力しました。

このPDFファイルを使ってフチなしの名刺サイズ(55x91mm)の用紙に印刷しました。 品質はわかりませんが、名刺サイズでなくとも用紙のサイズに合わせて印刷することもできるでしょう。

まとめ

A4サイズのコード表やCircle of 5thの表なんかは間違いなく便利なのですが、 カードサイズは消費するインクの量も少ないし、それなりに便利な場面があるかなと思っています。

A4サイズならレーザプリンタを使いますが、インクジェットが年末の年賀状と確定申告の時にしか活躍しないのは勿体無いので、これからもちょっとしたデータを印刷してみようと思います。

2010/10/01

無線LANからのアクセスをインターネットとの接続に制限する

FONの無線LANルータに限らず、最近のブロードバンドルータは無線LANクライアントを内部LANには接続させずにインターネットとの接続だけに限定する事ができるようになっています。

ブロードバンドルータを流用して無線LANのAccess Point(AP)にしていますが、どうやらルータ機能をONにしないと内部LANへの接続制限は有効にできないようだったので、iptablesを使ってeth2とeth1との接続を制限することにしました。

現状

ネットワークのおおまかな構成は次のようになっています。

おおまかなネットワークの構成図

加える制限の内容

現在はeth1,eth2間の通信は双方向で制限がなくなっているので、無線LANクライアントが接続する192.168.10.0/24ネットワークとの通信は双方向で遮断します。

ここまではiptablesのFORWARDチェインの話で、eth2内部の機器とalix自身の通信についてはもう少し検討が必要になります。

まずメンテナンスのためにALIXからeth2への通信は原則として自由に行なえるようにしました。 eth2からALIXへの接続についてはUDP経由でのDNS(port:53)とNTP(port:123)に限定しています。

また別の図にすると、次のような感じでしょうか。

ネットワークインタフェース間のフィルタールール

事前準備

ALIXはゲートウェイなので、NTPやらDNSやらの機能は持たせていませんでしたが、機器の数も限られているので無線LANクライアント向けにNTPとDNSの機能を提供することにしました。

ntpについてはlisten行を追加しています。

/etc/openntpd/ntpd.confの追加内容

listen on 192.168.10.1

DNSはforwarderに192.168.1/24にあるDNSサーバを追加したキャッシングサーバなので、 linsten-on, listen-on-ipv6行にeth1のインタフェースを指定して、allow-query行に192.168.10/24を追加したぐらいでしょうか。

iptablesの設定方法

以前から使っていた設定スクリプトにルールを追加しました。

iptables設定スクリプトに追加したルール

iptables -A FORWARD -i eth+ -o eth2 -j DROP
iptables -A FORWARD -i eth2 -o eth+ -j DROP
iptables -A FORWARD -i tun+ -o eth2 -j DROP
iptables -A FORWARD -i eth2 -o tun+ -j DROP
iptables -A INPUT -i eth2 -m state --state ESTABLISHED,RELATED -j ACCEPT
iptables -A INPUT -i eth2 -p udp --dport 53 -j ACCEPT
iptables -A INPUT -i eth2 -p udp --dport 123 -j ACCEPT
iptables -A INPUT -i eth2 -j DROP
iptables -A OUTPUT -o eth2 -m state --state NEW -j ACCEPT
iptables -A OUTPUT -o eth2 -m state --state ESTABLISHED,RELATED -j ACCEPT
iptables -A OUTPUT -o eth2 -j DROP

とりあえず動いていますが…

直接INPUTチェインを変更していてiptablesらしくないので、これは少し構成を変更しようと思っています。