はじめに
記事本編は通常の正規表現からです。最初が長いので興味ない方は飛ばしてください。
Javaでアプリを開発しています。
その際に, コマンド的なものを実装しようと思いました。
COMMAND <args: int>...
的な感じです。
この時に構文解析を行うために, 正規表現を用いようと思いました。
Javaも正規表現もたいして触ってないのでなにか訂正補足等してもらえるとありがたいです。
どうして正規表現なのか
個人的には過去に構文解析器をC++で実装したことがあるので, これでも全然よかったのですが,
- コマンド機能はたいして重要な機能ではなく, 文法も簡単なので, そこまで時間をかけたくない。
- 学校友達と開発するので, できるだけ慣れ親しんだ技術にしたい。
以上の理由から今回は正規表現で実装することにしました.
正規表現の難点
正規表現ですが, とにかく読みにくいです。例えば,
(\\d{4})-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])T([01]\\d|2[0-3]):([0-5]\\d):([0-5]\\d)([+-]([01]\\d|2[0-3]):([0-5]\\d))
これは何の正規表現か、ぱっと見でわかりますか?
答えは、2019-01-03T13:13:54+10:00のように, 日付T時刻[+-}時差の形を表す正規表現です。
正規表現になれた方だったらわかるかもしれないですが, これだけ冗長だとなかなかに難しいところがあると思います。
そこで, 今回はこういう冗長な正規表現をできるだけわかりやすく書くための手段を考えていこうと思います。
目標
データの説明
今回は
LOGIN Admin 190.57.201.56 2019-01-03T13:13:54+10:00
のように,
<AccessType> <UserName> <IPAddress> <Datetime>
AccessType := LOGIN|LOGOUT|FAILED
UserName := [a-zA-Z0-9_]+
の形式をとるログデータを取り扱います。
日付, 時刻はいずれも2けたになるように0埋めをしています。
機械生成したプログラムなのでIPAddressがあり得ない数字になったりしていますがご了承ください。
正規表現の目標はこの中からAccessType=FAILEDとなった行のみ抜き取り,
class LogEntry {
String type;
String user;
String ip;
String datetime;
}
というクラスに格納し,
static ArrayList<LogEntry> failed_entries = new ArrayList<LogEntry>();
の中に入れることです。
こうすれば UserNameやIPAddress順にソートして解析することが可能になるでしょう。
準備
正規表現にはあまり関係ないですが, ターゲットのファイルとmain関数について一応説明します。読まなくても困ることはないです。
目標ファイルは.log
, 最初の五件のみ下に示します。
LOGOUT Valid_User 57.220.92.48 2019-01-03T17:07:42+08:00
LOGOUT Kaya_Kentaro 132.139.157.172 2019-01-06T12:23:45+09:00
LOGIN Kaya_Kentaro 132.139.157.172 2019-01-09T05:57:58+09:00
FAILED Anonymous 245.11.130.168 2019-01-13T00:24:28+09:00
FAILED Anonymous 245.11.130.168 2019-01-14T11:24:51-02:00
正規表現はString LOG_REGEX
に格納し, Pattern LOG_PATTERN
にコンパイルします。
main関数はこんな感じです。
public static void main(String[] args) {
BufferedReader log_file = null;
// ファイルを開く
try {
log_file = new BufferedReader(new FileReader(".log"));
} catch (FileNotFoundException e) {
System.out.println(e.getMessage());
System.exit(1);
}
String line;
try {
// マッチ処理を行い, failed_entriesにadd
while (Objects.nonNull(line = log_file.readLine())) {
Matcher log_matched = LOG_PATTERN.matcher(line);
if (log_matched.find()) {
LogEntry entry = new LogEntry(log_matched.group(1), log_matched.group(2), log_matched.group(3), log_matched.group(4));
failed_entries.add(entry);
}
}
} catch (IOException e) {
System.out.println(e.getMessage());
System.exit(1);
}
try {
log_file.close();
} catch (IOException e) {
System.out.println(e.getMessage());
System.exit(1);
}
// 最初の十件表示
for (int i = 0; i < Math.min(failed_entries.size(), 10); i++) {
LogEntry entry = failed_entries.get(i);
System.out.println(String.format("Type: %s, User: %s, IP: %s, Datetime: %s", entry.type, entry.user, entry.ip, entry.datetime));
}
}
通常の正規表現
何も考えずに正規表現を書くとこんな感じになります。
final static String LOG_REGEX = "^[ ]*(FAILED)[ ]*(\\w+)[ ]*(\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3})[ ]*((\\d{4})-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])T([01]\\d|2[0-3]):([0-5]\\d):([0-5]\\d)([+-]([01]\\d|2[0-3]):([0-5]\\d)))[ ]*$";
冗長で複雑でとても読む気になれませんよね。そうした印象をもたらすのは, 正規表現自体の長さにもあるでしょうが, 正規表現が一つなぎになっていて, どこがなんなのか理解しにくいです。
名づけを行う
正規表現は(?<char>.)
のように, グループに名前をつけることができ, Javaでは
matcher.group("char")
のようにして抜き取ることができます。
これを使えば正規表現のどのグループが何を表しているのかわかりやすくなって, Javaのほうもどのグループを抜き出しているかわかりやすくなり, 可読性が上がります。
試しにLOG_REGEXの各グループに名前を付けて, わかりやすいように改行をしてみると,
final static String LOG_REGEX =
"^[ ]*(?<AccessType>FAILED)[ ]*"
"(?<UserName>\\w+)[ ]*"
"(?<IPAddress>\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3})[ ]*"
"(?<Datetime>(\\d{4})-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])T([01]\\d|2[0-3]):([0-5]\\d):([0-5]\\d)([+-]([01]\\d|2[0-3]):([0-5]\\d)))[ ]*$";
となります. ちょっとは読みやすくなりましたかね?
Pythonだとこんな感じで途中に改行しても間にバックスラッシュ入れれば文字列リテラルの連続は勝手に結合してくれるのですが, Javaだとそうもいかないですかね...
難点
こんな感じにグループに名づけを行えば, ちょっとは分かりやすくなるのですが, 実際に問題が発生してデバッグをしようと思ったらdatetimeのような冗長な正規表現を読むことになります。もう少し細かく名前付けを行えばいいでしょうが, そうするとソースコードが長くなってしまったりかえって理解に時間がかかる可能性もあるので難しいかと思っています。
変数とフォーマットを用いる。
JavaのStringクラスにはformatというメソッドが存在しています。これは%s said %s
のようなフォーマット文字列を第一引数にとり, 順番にname, message...のように, 埋め込まれる変数を残りの引数にとれば, Kato said Hello World
のように変数展開してくれるメソッドです。
これを使えば, 正規表現の部分的な文字列を変数として定義して, 後から埋め込むことで, わかりやすくなることが期待できます。
こんな感じです。
final static String TARGET_ACCESS_TYPE = "FAILED";
final static String USERNAME = "\\w+";
final static String IP_ADDRESS = "\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}";
final static String YEAR = "\\d{4}";
final static String MONTH = "0[1-9]|1[0-2]";
final static String DAY = "0[1-9]|[12]\\d|3[01]";
final static String HOUR = "[01]\\d|2[0-3]";
final static String MINUTE = "[0-5]\\d";
final static String SECOND = MINUTE;
final static String TZ = String.format("[+-](%s):(%s)", HOUR, MINUTE);
final static String TIMESTAMP = String.format("(%s)-(%s)-(%s)T(%s):(%s):(%s)(%s)", YEAR, MONTH, DAY, HOUR, MINUTE, SECOND, TZ);
final static String LOG_REGEX = String.format("^[ ]*(?<AccessType>%s)[ ]*(?<Username>%s)[ ]*(?<IpAddress>%s)[ ]*(?<TimeStamp>%s)[ ]*$", TARGET_ACCESS_TYPE, USERNAME, IP_ADDRESS, TIMESTAMP);
どうでしょうか? TIMESTAMPがYEARやHOURなどから作られ, それがまたLOG_REGEXに利用され... というように, 各グループが何をしているのかわかりやすくなったのではないかと思います。
また, この方法を使えば, 変数としての再利用や正規表現がうまく働かないときにYEARやMONTHをチェックしたりして, デバッグすることも簡単になるかと思います。
Readable Codeにも変数や関数に切り出すことが紹介されているように, 変数への切り出しは可読性をあげるために非常に大事なことだと思います。
難点
この方法の難点は名前空間が汚れることにあるのかなと思っています。YEARやMONTHなど, 後から再利用するならともかく, 使わない場合は邪魔くさいです。これを回避するためには新しくクラスを定義して, private変数にしてしまうなどのアプローチが考えられます。
また, 正直言ってしまうと個人的にJavaのフォーマットは分かりにくい気がします。
Pythonのf文字列ならば
LOG_REGEX = f"^[ ]*<?AccessType>{ACCESSTYPE}..."
のように, エディタのハイライト機能もあってわかりやすい気がしますが, うちのエディタは書式指定子をハイライトしてくれないのでどこに変数埋め込まれるか探すのに苦労したりします。なにかいい感じの拡張機能でもあればいいんですけど...
実行
main関数を実行して, 最初の五件を表示させます。
Type: FAILED, User: Anonymous, IP: 245.11.130.168, Datetime: 2019-01-13T00:24:28+09:00
Type: FAILED, User: Anonymous, IP: 245.11.130.168, Datetime: 2019-01-14T11:24:51-02:00
Type: FAILED, User: Anonymous, IP: 245.11.130.168, Datetime: 2019-01-23T07:56:33+10:00
Type: FAILED, User: Anonymous, IP: 245.11.130.168, Datetime: 2019-01-28T05:43:39+09:00
Type: FAILED, User: __in_valid__, IP: 247.63.33.200, Datetime: 2019-02-03T08:06:41+08:00
Type: FAILED, User: Anonymous, IP: 245.11.130.168, Datetime: 2019-02-07T07:27:04-02:00
Type: FAILED, User: Anonymous, IP: 245.11.130.168, Datetime: 2019-02-08T07:36:15-02:00
Type: FAILED, User: Anonymous, IP: 245.11.130.168, Datetime: 2019-02-10T11:41:07+10:00
Type: FAILED, User: Anonymous, IP: 245.11.130.168, Datetime: 2019-02-20T23:55:25-02:00
Type: FAILED, User: __in_valid__, IP: 247.63.33.200, Datetime: 2019-02-22T22:49:39-02:00
うまく表示されました。
所感
何か言うことがあった気がしますけど忘れたので思いついたら書きます。