CodeIQの「複数書式が混在するログをやっつける」を解いたよ

http://codeiq.hatenablog.com/entry/2013/08/19/122201を解いたのでせっかくだしコードを置いておこうかなーと。というわけで私が「Node.jsが1名ということで」の1名なのでした。ついでに「全体を一気にparseする正規表現を用意派」です。


node.jsで書くにしても普通に書いたのではおもしろくないので、とりあえずストリームで実装してみました。
TransformStreamはGitHub - sasaplus1/ltsv.js: LTSV parser and formatterのLtsvToJsonStreamを書く際に苦労した分、今では大分慣れたので割と簡単に書けたかなと。
多分一番苦労したのは正規表現とか、エスケープの辺りかな…… エスケープはあまり褒められたものじゃないかも。


クラス名は割とかっこいい名前にできたかなーと思います。もうちょっと名は体を表す名前にも出来たんじゃないかとは思いますが。
コメントはふざけすぎですね。きっと疲れてたんです。公私ともにバタバタしてたしね。

設問1

Apache common形式(?) / combined形式(?)のログをパースする問題。

combined-tagomoris.js
#!/usr/bin/env node

// node.js ver.0.10.15で実行してください。
//
//     $ node combined-tagomoris.js < logs.txt
//
// で動作確認を行いました。

// 基本的な動作:
//
// 1. ストリームから流れてきた文字列を行ごとに分割
// 2. 各行に対して正規表現でマッチを試みる
// 3. マッチした結果からJSONを生成
// 4. JSONをストリームに出力
// 5. 1.に戻る

var stream = require('stream'),
    util = require('util');

// TagomoriStreamをTransformStreamから継承します
util.inherits(TagomoriStream, stream.Transform);

/**
 * スーパーエンジニアtagomorisさんの
 * ログに対する熱い思いをストリームにしたクラスです。
 *
 * @constructor
 */
function TagomoriStream() {
  // TransformStreamのコンストラクタを自身のインスタンスで呼び出します
  stream.Transform.call(this, {
    objectMode: true,    // オブジェクトストリームとして振る舞う
    decodeStrings: true  // バッファを文字列にデコードする
  });

  this.chunk_ = '';  // その時点でのストリームの末尾の文字列
  this.queue_ = [];  // 改行で分割した処理単位のキュー

  // ログを解析するための正規表現
  this.logRegExp_ = new RegExp(
      /* vhost                */ '("[^"]+")? ?' +
      /* rhost                */ '([\\d.]+)? ?' +
      /* -                    */ '- ?' +
      /* user                 */ '(\\S+)? ?' +
      /* time                 */ '(\\[[^\\]]+\\])? ?' +
      /* method path protocol */ '"(\\S+)? ?(\\S+)? ?(\\S+)?" ?' +
      /* status               */ '(\\d+)? ?' +
      /* bytes                */ '(\\d+)? ?' +
      /* referer              */ '("[^"]+")? ?' +
      /* agent                */ '(".+")? ?' +
      /* duration             */ '(\\d+)?');
}

/**
 * チャンク毎に呼ばれるメソッドです。
 *
 * @private
 * @param {Buffer|String} chunk チャンク
 * @param {String} encoding チャンクの文字コード
 * @param {Function} callback コールバック関数
 */
TagomoriStream.prototype._transform = function(chunk, encoding, callback) {
  // 前回のチャンクの末尾と今回のチャンクを連結した上で行ごとに分割します
  var lines = (this.chunk_ + chunk).split(/\r?\n/);

  // 末尾を残し、それ以外をキューに追加します
  this.chunk_ = lines.pop();
  this.queue_ = this.queue_.concat(lines);

  this.send_(callback);
};

/**
 * 最後のチャンクを処理する際に呼ばれるメソッドです。
 *
 * @private
 * @param {Function} callback コールバック関数
 */
TagomoriStream.prototype._flush = function(callback) {
  var lines = this.chunk_.split(/\r?\n/);

  // 改行で終わるファイルなど、
  // 空文字が最後に来てしまう場合はキューに含めずに削除します
  if (lines[lines.length - 1] === '') {
    lines.pop();
  }

  this.chunk_ = '';
  this.queue_ = this.queue_.concat(lines);

  this.sendAll_(callback);
};

/**
 * キューにあるデータを処理します。
 *
 * @private
 * @param {Function} callback コールバック関数
 */
TagomoriStream.prototype.send_ = function(callback) {
  var i, len, line, json;

  // 現在キューにある行を処理します
  for (i = 0, len = this.queue_.length; i < len; ++i) {
    // 行を取り出し、解析します
    line = this.queue_.shift();
    json = this.parse_(line);

    // 出力先のストリームがデータを受け付けなかった場合
    if (!this.push(json + '\n')) {
      // データを戻します
      lines.unshift(line);
      break;
    }
  }

  if (typeof callback === 'function') {
    callback(null);
  }
};

/**
 * キューにあるデータがなくなるまで処理を繰り返します。
 *
 * @private
 * @param {Function} callback コールバック関数
 */
TagomoriStream.prototype.sendAll_ = function(callback) {
  // キューのデータを処理します
  this.send_();

  // キューにデータが残っている場合
  if (this.queue_.length > 0) {
    // sendAll_を再帰的に呼び出します
    setImmediate(function(that) {
      that.sendAll_(callback);
    }, this);

    return;
  }

  // これ以上処理するデータが存在しないことを知らせます
  this.push(null);

  if (typeof callback === 'function') {
    callback(null);
  }
};

/**
 * ログの行を解析・変換してJSON文字列として返します。
 *
 * @private
 * @param {String} line ログの行
 * @return {String} 行を解析・変換したJSON文字列
 */
TagomoriStream.prototype.parse_ = function(line) {
  var matches = this.logRegExp_.exec(line),
      obj, time, month;

  obj = {
    vhost:    matches[1]  || null,
    rhost:    matches[2]  || null,
    user:     matches[3]  || null,
    time:     matches[4]  || null,
    method:   matches[5]  || null,
    path:     matches[6]  || null,
    protocol: matches[7]  || null,
    status:   matches[8]  || null,
    bytes:    matches[9]  || null,
    referer:  matches[10] || null,
    agent:    matches[11] || null,
    duration: matches[12] || null
  };

  // 囲み文字を削除します
  obj.vhost   && (obj.vhost   = obj.vhost.slice(1, -1));
  obj.time    && (obj.time    = obj.time.slice(1, -1));
  obj.referer && (obj.referer = obj.referer.slice(1, -1));
  obj.agent   && (obj.agent   = obj.agent.slice(1, -1));

  // エスケープされた文字を元の文字に戻します
  obj.path  && (obj.path  = obj.path.replace(/\\"/g, '"').replace(/\\t/g, '\t'));
  obj.agent && (obj.agent = obj.agent.replace(/\\"/g, '"').replace(/\\/g, '\\'));

  // 文字列から整数にします
  obj.status   && (obj.status   = parseInt(obj.status  , 10));
  obj.bytes    && (obj.bytes    = parseInt(obj.bytes   , 10));
  obj.duration && (obj.duration = parseInt(obj.duration, 10));

  // 日付の書式を変更します
  if (obj.time) {
    // 日付と時刻の間のコロンをスペースに置換してDateがパースできるようにします
    time = new Date(obj.time.replace(':', ' '));

    // 先頭をゼロパディングをした上で月を文字列にします
    month = ('0' + (time.getMonth() + 1)).slice(-2);

    // "YYYY-MM-DD hh:mm:ss UTC"の形式に置換します
    obj.time = obj.time.replace(
        /(\d{2})\/\w{3}\/(\d{4}):(\d{2}):(\d{2}):(\d{2}) (\+\d{4})/,
        '$2-' + month + '-$1 $3:$4:$5 $6');
  }

  // JSONに変換して返します
  return JSON.stringify(obj);
};

// 標準入力のデータをストリームを経由して標準出力に出力します
process.stdin.pipe(new TagomoriStream).pipe(process.stdout);

設問2

LTSV形式のログをパースする問題。同じくCodeIQのはてなの問題で解いたし、そもそもGitHub - sasaplus1/ltsv.js: LTSV parser and formatter書いてるのでまあ。

ltsv-tagomoris.js
#!/usr/bin/env node

// node.js ver.0.10.15で実行してください。
//
//     $ node ltsv-tagomoris.js < logs.ltsv
//
// で動作確認を行いました。

// 基本的な動作:
//
// 1. ストリームから流れてきた文字列を行ごとに分割
// 2. 各レコードを解析
// 3. レコードからJSONを生成
// 4. JSONをストリームに出力
// 5. 1.に戻る

var stream = require('stream'),
    util = require('util');

// TagomoriStreamをTransformStreamから継承します
util.inherits(TagomoriStream, stream.Transform);

/**
 * スーパーエンジニアtagomorisさんの
 * LTSVうまああああああい!という思いをストリームにしたクラスです。
 *
 * @constructor
 */
function TagomoriStream() {
  // TransformStreamのコンストラクタを自身のインスタンスで呼び出します
  stream.Transform.call(this, {
    objectMode: true,    // オブジェクトストリームとして振る舞う
    decodeStrings: true  // バッファを文字列にデコードする
  });

  this.chunk_ = '';  // その時点でのストリームの末尾の文字列
  this.queue_ = [];  // 改行で分割した処理単位のキュー
}

/**
 * チャンク毎に呼ばれるメソッドです。
 *
 * @private
 * @param {Buffer|String} chunk チャンク
 * @param {String} encoding チャンクの文字コード
 * @param {Function} callback コールバック関数
 */
TagomoriStream.prototype._transform = function(chunk, encoding, callback) {
  // 前回のチャンクの末尾と今回のチャンクを連結した上で行ごとに分割します
  var lines = (this.chunk_ + chunk).split(/\r?\n/);

  // 末尾を残し、それ以外をキューに追加します
  this.chunk_ = lines.pop();
  this.queue_ = this.queue_.concat(lines);

  this.send_(callback);
};

/**
 * 最後のチャンクを処理する際に呼ばれるメソッドです。
 *
 * @private
 * @param {Function} callback コールバック関数
 */
TagomoriStream.prototype._flush = function(callback) {
  var lines = this.chunk_.split(/\r?\n/);

  // 改行で終わるファイルなど、
  // 空文字が最後に来てしまう場合はキューに含めずに削除します
  if (lines[lines.length - 1] === '') {
    lines.pop();
  }

  this.chunk_ = '';
  this.queue_ = this.queue_.concat(lines);

  this.sendAll_(callback);
};

/**
 * キューにあるデータを処理します。
 *
 * @private
 * @param {Function} callback コールバック関数
 */
TagomoriStream.prototype.send_ = function(callback) {
  var i, len, line, json;

  // 現在キューにある行を処理します
  for (i = 0, len = this.queue_.length; i < len; ++i) {
    // 行を取り出し、解析します
    line = this.queue_.shift();
    json = this.parse_(line);

    // 出力先のストリームがデータを受け付けなかった場合
    if (!this.push(json + '\n')) {
      // データを戻します
      lines.unshift(line);
      break;
    }
  }

  if (typeof callback === 'function') {
    callback(null);
  }
};

/**
 * キューにあるデータがなくなるまで処理を繰り返します。
 *
 * @private
 * @param {Function} callback コールバック関数
 */
TagomoriStream.prototype.sendAll_ = function(callback) {
  // キューのデータを処理します
  this.send_();

  // キューにデータが残っている場合
  if (this.queue_.length > 0) {
    // sendAll_を再帰的に呼び出します
    setImmediate(function(that) {
      that.sendAll_(callback);
    }, this);

    return;
  }

  // これ以上処理するデータが存在しないことを知らせます
  this.push(null);

  if (typeof callback === 'function') {
    callback(null);
  }
};

/**
 * レコードを解析・変換してJSON文字列として返します。
 *
 * @private
 * @param {String} record LTSVのレコード
 * @return {String} レコードを解析・変換したJSON文字列
 */
TagomoriStream.prototype.parse_ = function(record) {
  var fields = record.split('\t'),
      obj;

  // combind-tagomoris.jsの出力とdiffをしやすいように
  // 先にnullを代入しておきます(あまり良くないですが)
  obj = {
    vhost:    null,
    rhost:    null,
    user:     null,
    time:     null,
    method:   null,
    path:     null,
    protocol: null,
    status:   null,
    bytes:    null,
    referer:  null,
    agent:    null,
    duration: null
  };

  // フィールドを解析します
  fields.forEach(function(field) {
    var separatorPos = field.indexOf(':'),
        label, value;

    // セパレータがない場合は無視します
    if (separatorPos === -1) {
      return;
    }

    label = field.slice(0, separatorPos);
    value = field.slice(separatorPos + 1);

    obj[label] = value;
  });

  // エスケープされた文字を元の文字に戻します
  obj.path  && (obj.path  = obj.path.replace(/\\"/g, '"').replace(/\\t/g, '\t'));
  obj.agent && (obj.agent = obj.agent.replace(/\\"/g, '"').replace(/\\/g, '\\'));

  // 文字列から整数にします
  obj.status   && (obj.status   = parseInt(obj.status  , 10));
  obj.bytes    && (obj.bytes    = parseInt(obj.bytes   , 10));
  obj.duration && (obj.duration = parseInt(obj.duration, 10));

  // 日付の書式を変更します
  if (obj.time) {
    // 日付と時刻の間のコロンをスペースに置換してDateがパースできるようにします
    time = new Date(obj.time.replace(':', ' '));

    // 先頭をゼロパディングをした上で月を文字列にします
    month = ('0' + (time.getMonth() + 1)).slice(-2);

    // "YYYY-MM-DD hh:mm:ss UTC"の形式に置換します
    obj.time = obj.time.replace(
        /(\d{2})\/\w{3}\/(\d{4}):(\d{2}):(\d{2}):(\d{2}) (\+\d{4})/,
        '$2-' + month + '-$1 $3:$4:$5 $6');
  }

  // JSONに変換して返します
  return JSON.stringify(obj);
};

// 標準入力のデータをストリームを経由して標準出力に出力します
process.stdin.pipe(new TagomoriStream).pipe(process.stdout);

お気に入り

process.stdin.pipe(new TagomoriStream).pipe(process.stdout);

最後のこれがとてもお気に入り。一行で書けちゃう。


最初は正規表現で一回でパースできるのかなあ、などと思いながら書いたのだけど、なんとかパースできてよかったと思います。
まだまだ正規表現に関する知識は甘いのだけどねー。


まあ、やはりコードを書くと勉強になるというか、いろいろ為になるなあと思うのでした。
これからも精進していけたらいいなー。