0. はじめに
ある日突然,サイズがギガバイト級のテキストファイルを大量に処理することになった。とはいってもファイルの先頭および最後の一行だけを抽出するという簡単なお仕事である。
最初 PowerShell でスクリプトを作った。ファイル名 $filename
,出力行数 $line
とするとコア部分は下記のように一行で済む。
Get-Content $filename -TotalCount $line
Get-Content $filename -Tail $line
文字コードの変換も -Encode UTF8
オプションを付ければ簡単に出来る。ただし,入力ファイルのサイズが大きくなると激しく遅くなってしまう。どうやらファイルを全部読み込んでいるようだ。
UNIX の tail コマンドはファイルの末尾から必要最小限分しか読み込まないらしく,かなり賢い作りである。今回,それを模して作ることにした。
1. 仕様案(お品書き)
- 出力行数
UNIX の tail コマンドはデフォルトだと10行だが,前述の通りデフォルトでは1行とした。ただし,出力する行数をオプションにて指定できるようにする。Windows のコマンドらしく,大文字・小文字は区別しない。/N10
でも-n10
でも/N:10
でも-n=10
でも可とするが,/N 10
や-n 10
のようにスペースを挟むものは不可とする。入力ファイルが指定した行数よりも短い場合は全行数を表示するものとし,その場合でもエラーメッセージを表示することはしない。 - 文字コード
デフォルトではシステム規定値(日本語版 Windows だと Shift-JIS)として,/U
または-u
オプションを指定すると UTF8 とする。自動判別はしない。 - 標準入力からのリダイレクト
入力ファイル名が指定され,かつ標準入力へのリダイレクトもある場合はリダイレクトを優先する。どちらも指定されていない場合はヘルプメッセージを表示する。 - 入力ファイルのサイズ
入力ファイルのサイズはギガバイト級に達する可能性がある。すなわち入力ファイルを最初に一括で読み込んでオンメモリ上で処理することはしない(できない)ものとする。
2. 開発言語の選定
PowerShell スクリプト内で標準入力がリダイレクトされているかどうかを判別する方法が分からない。ということで C# にした。文字コード変換が無ければ C を採用したかもしれない。
3. まずは head コマンドを作る
この後に作る tail コマンドの習作(練習)である。標準入力がリダイレクトされているかどうかは Console.IsInputRedirected
一発で分かる。標準入力から読み込む場合もファイルから読み込む場合も StreamReader
で一本化できるのは嬉しいことだ。
先頭から指定行数分を出力するコア処理部を以下に示すが,とても簡単である。
for( int i = 0; i < output_line && !reader.EndOfStream; i++ ) {
string s = reader.ReadLine();
Console.Out.WriteLine( s );
}
head コマンドの実装コードはコチラ
//------------------------------------------------------------------------------
// テキストファイルの先頭の数行を表示します。
//------------------------------------------------------------------------------
using System;
using System.IO;
using System.Text;
using System.Reflection;
using System.Text.RegularExpressions;
using System.Runtime.InteropServices;
class HEAD {
//--------------------------------------------------------------------------
// グローバル変数
//--------------------------------------------------------------------------
static string input_file = ""; // 入力ファイル名
static int output_line = 1; // 出力行数
static Encoding encoding = Encoding.Default; // 文字コード
//--------------------------------------------------------------------------
// ヘルプメッセージ
//--------------------------------------------------------------------------
static int Usage() {
Console.Error.WriteLine( "テキストファイルの先頭の数行を表示します。" );
Console.Error.WriteLine( "" );
Console.Error.WriteLine( "HEAD(.EXE) (オプション) [ファイル名]" );
Console.Error.WriteLine( "" );
Console.Error.WriteLine( "[ファイル名] 入力ファイル名を指定します。省略すると標準入力から入力します。" );
Console.Error.WriteLine( "" );
Console.Error.WriteLine( " /N:[数値] 出力行数を指定します。デフォルトは {0} 行です。", output_line );
Console.Error.WriteLine( " /U 文字コードを UTF8 に変更します。" );
return( -1 );
}
//--------------------------------------------------------------------------
// オプション解析
//--------------------------------------------------------------------------
static int GetOption( string[] args ) {
Match m;
for( int i = 0; i < args.Length; i++ ) {
string s = args[i];
if( ( m = Regex.Match( s, @"^[-/][Nn][:=]?(\d+)$" ) ).Success ) {
if( !int.TryParse( m.Groups[1].Value, out output_line ) || output_line < 1 ) {
Console.Error.WriteLine( "出力行数の指定 {0} が不正です!!", m.Groups[1].Value );
return( -1 );
}
} else if( Regex.IsMatch( s, @"^[-/][Uu]$" ) ) {
encoding = Encoding.UTF8;
} else if( s[0] == '/' || s[0] == '-' ) {
Console.Error.WriteLine( "オプション {0} が不正です!!", s );
return( -1 );
} else if( input_file == "" ) {
input_file = s;
} else {
Console.Error.WriteLine( "入力ファイル名は既に指定されています!!" );
return( -1 );
}
}
return( 0 );
}
//--------------------------------------------------------------------------
// メイン関数
//--------------------------------------------------------------------------
static int Main( string[] args ) {
bool redirect = Console.IsInputRedirected;
//----------------------------------------------------------------------
// ヘルプメッセージ
//----------------------------------------------------------------------
if( args.Length == 0 && !redirect ) return Usage();
//----------------------------------------------------------------------
// オプション解析
//----------------------------------------------------------------------
if( GetOption( args ) != 0 ) return( -1 );
//----------------------------------------------------------------------
// 入力ファイルのオープン
//----------------------------------------------------------------------
StreamReader reader;
if( redirect ) {
reader = new StreamReader( Console.OpenStandardInput(), encoding );
} else if( input_file == "" ) {
Console.Error.WriteLine( "入力ファイルの指定がありません!!" );
return( -1 );
} else if( !File.Exists( input_file ) ) {
Console.Error.WriteLine( "入力ファイル {0} は存在しません!!", input_file );
return( -1 );
} else {
reader = new StreamReader( input_file, encoding );
}
//----------------------------------------------------------------------
// 入力ファイルの読み込み
//----------------------------------------------------------------------
for( int i = 0; i < output_line && !reader.EndOfStream; i++ ) {
string s = reader.ReadLine();
Console.Out.WriteLine( s );
}
//----------------------------------------------------------------------
// 入力ファイルのクローズ
//----------------------------------------------------------------------
reader.Close();
return( 0 );
}
}
4. 次に tail コマンドを作る
4.1 ファイル末尾の改行コード問題
ファイルの末尾から検索して最初に見つけた改行コードの次のバイトが最終行の先頭位置である。ただし,ファイルによってはファイル末尾が改行コードになっている場合がある。その場合は最初の改行コードをスキップして,二番目に見つけた改行コードの次のバイトを最終行の先頭位置とする。
4.2 標準入力から読み込む場合
標準入力がリダイレクトされている場合,いったん StreamReader
の ReadToEnd()
でストリームを一括して読み込む。
var reader = new StreamReader( Console.OpenStandardInput(), encoding );
string buf = reader.ReadToEnd();
reader.Close();
文字列の末尾から改行コードを検索するようにした。
int count = 0;
int i = buf.Length - 1;
int pos = 0;
if( buf[i] == '\n' ) i--;
while( i >= 0 ) {
if( buf[i] == '\n' ) {
if( ++count >= output_line ) {
pos = i + 1;
break;
}
}
i--;
}
Console.Out.Write( buf.Substring( pos ) );
ストリームのサイズがメモリに収まり切らない場合を想定していない。その場合は,ストリームから一行ずつ読み込んでリングバッファに登録する等の手法が必要になるが,面倒なので採用しなかった。後述するファイルから読み込む場合と出来るだけコードを似せたかったこともある。
4.3 ファイルから読み込む場合(当初案)
ファイルから読み込む場合はファイルサイズが分かっているので思い切った最適化が可能である。ファイルを全部読み込む必要はなく,ファイルの末尾から必要な分だけ読み込めばよい。当初は FileStream
の Seek
でファイルの末尾まで飛び,固定サイズ(4096 バイト)のバッファに読み込んで出力行数分の改行コードを検索するようにした。
int len = 4096;
var buf = new byte[len];
var stream = new FileStream( input_file, FileMode.Open, FileAccess.Read );
int count = 0;
int i;
long pos = 0;
long offset = stream.Length;
if( offset >= len ) { // 同じ処理(*)を繰り返しているのがアホっぽいが
offset -= len; // ファイル末尾の改行コード問題のせいである。
} else { // ローカル関数を使えばシンプルに書けるだろう。
len = (int)offset; //
offset = 0; //
} //
stream.Seek( offset, SeekOrigin.Begin ); //
stream.Read( buf, 0, len ); //
i = len - 1; //
if( buf[i] == '\n' ) i--;
for(;;) {
while( i >= 0 ) {
if( buf[i] == '\n' ) {
if( ++count >= output_line ) {
pos = offset + i + 1;
goto BREAK;
}
}
i--;
}
if( offset == 0 ) break;
if( offset >= len ) { // (*)
offset -= len; //
} else { //
len = (int)offset; //
offset = 0; //
} //
stream.Seek( offset, SeekOrigin.Begin ); //
stream.Read( buf, 0, len ); //
i = len - 1; //
}
BREAK:
var bytes = new byte[stream.Length - pos];
stream.Seek( pos, SeekOrigin.Begin );
stream.Read( bytes, 0, bytes.Length );
stream.Close();
Console.Out.Write( encoding.GetString( bytes ) );
mmap が実装される以前,初期の UNIX の tail コマンドはこんな感じのコードだったと推察する。
4.4 ファイルから読み込む場合(改善案)
しかし最終的には MemoryMappedFile
を用いるようにした。OS の仮想記憶(ページング)と融合した仕組みで,ファイルをマッピングした時点ではファイルの読み込むは発生せず,参照した領域のページ単位で自動的に読み込みが行われる。つまり面倒な処理を OS に任せたおかげで,コードは随分とシンプルになった。
var info = new FileInfo( input_file );
var map = MemoryMappedFile.CreateFromFile( input_file, FileMode.Open );
var acc = map.CreateViewAccessor();
int count = 0;
long i = info.Length - 1;
long pos = 0;
if( acc.ReadByte( i ) == '\n' ) i--;
while( i >= 0 ) {
if( acc.ReadByte( i ) == '\n' ) {
if( ++count >= output_line ) {
pos = i + 1;
break;
}
}
i--;
}
var bytes = new byte[info.Length - pos];
acc.ReadArray<byte>( pos, bytes, 0, bytes.Length );
Console.Out.Write( encoding.GetString( bytes ) );
4.5 実装コード
実装コードを以下に示す。標準入力がリダイレクトされているときとファイルから読み込む場合で処理を共通化したかったが,残念ながら自分の技術力では無理だった。
tail コマンドの実装コードはコチラ
//------------------------------------------------------------------------------
// テキストファイルの最後の数行を表示します。
//------------------------------------------------------------------------------
using System;
using System.IO;
using System.Text;
using System.Reflection;
using System.IO.MemoryMappedFiles;
using System.Text.RegularExpressions;
using System.Runtime.InteropServices;
class TAIL {
//--------------------------------------------------------------------------
// グローバル変数
//--------------------------------------------------------------------------
static string input_file = ""; // 入力ファイル名
static int output_line = 1; // 出力行数
static Encoding encoding = Encoding.Default; // 文字コード
//--------------------------------------------------------------------------
// ヘルプメッセージ
//--------------------------------------------------------------------------
static int Usage() {
Console.Error.WriteLine( "テキストファイルの最後の数行を表示します。" );
Console.Error.WriteLine( "" );
Console.Error.WriteLine( "TAIL(.EXE) (オプション) [ファイル名]" );
Console.Error.WriteLine( "" );
Console.Error.WriteLine( "[ファイル名] 入力ファイル名を指定します。省略すると標準入力から入力します。" );
Console.Error.WriteLine( "" );
Console.Error.WriteLine( " /N:[数値] 出力行数を指定します。デフォルトは {0} 行です。", output_line );
Console.Error.WriteLine( " /U 文字コードを UTF8 に変更します。" );
return( -1 );
}
//--------------------------------------------------------------------------
// オプション解析
//--------------------------------------------------------------------------
static int GetOption( string[] args ) {
Match m;
for( int i = 0; i < args.Length; i++ ) {
string s = args[i];
if( ( m = Regex.Match( s, @"^[-/][Nn][:=]?(\d+)$" ) ).Success ) {
if( !int.TryParse( m.Groups[1].Value, out output_line ) || output_line < 1 ) {
Console.Error.WriteLine( "出力行数の指定 {0} が不正です!!", m.Groups[1].Value );
return( -1 );
}
} else if( Regex.IsMatch( s, @"^[-/][Uu]$" ) ) {
encoding = Encoding.UTF8;
} else if( s[0] == '/' || s[0] == '-' ) {
Console.Error.WriteLine( "オプション {0} が不正です!!", s );
return( -1 );
} else if( input_file == "" ) {
input_file = s;
} else {
Console.Error.WriteLine( "入力ファイル名は既に指定されています!!" );
return( -1 );
}
}
return( 0 );
}
//--------------------------------------------------------------------------
// メイン関数
//--------------------------------------------------------------------------
static int Main( string[] args ) {
bool redirect = Console.IsInputRedirected;
//----------------------------------------------------------------------
// ヘルプメッセージ
//----------------------------------------------------------------------
if( args.Length == 0 && !redirect ) return Usage();
//----------------------------------------------------------------------
// オプション解析
//----------------------------------------------------------------------
if( GetOption( args ) != 0 ) return( -1 );
if( redirect ) {
//------------------------------------------------------------------
// 標準入力からすべて読み込み,末尾から検索する
//------------------------------------------------------------------
var reader = new StreamReader( Console.OpenStandardInput(), encoding );
string buf = reader.ReadToEnd();
reader.Close();
int count = 0;
int i = buf.Length - 1;
int pos = 0;
if( buf[i] == '\n' ) i--;
while( i >= 0 ) {
if( buf[i] == '\n' ) {
if( ++count >= output_line ) {
pos = i + 1;
break;
}
}
i--;
}
Console.Out.Write( buf.Substring( pos ) );
} else if( input_file == "" ) {
Console.Error.WriteLine( "入力ファイルの指定がありません!!" );
return( -1 );
} else if( !File.Exists( input_file ) ) {
Console.Error.WriteLine( "入力ファイル {0} は存在しません!!", input_file );
return( -1 );
} else {
//------------------------------------------------------------------
// メモリマップドファイルに割り当てて,末尾から検索する
//------------------------------------------------------------------
var info = new FileInfo( input_file );
using( var map = MemoryMappedFile.CreateFromFile( input_file, FileMode.Open ) ) {
using( var acc = map.CreateViewAccessor() ) {
int count = 0;
long i = info.Length - 1;
long pos = 0;
if( acc.ReadByte( i ) == '\n' ) i--;
while( i >= 0 ) {
if( acc.ReadByte( i ) == '\n' ) {
if( ++count >= output_line ) {
pos = i + 1;
break;
}
}
i--;
}
var bytes = new byte[info.Length - pos];
acc.ReadArray<byte>( pos, bytes, 0, bytes.Length );
Console.Out.Write( encoding.GetString( bytes ) );
}
}
}
return( 0 );
}
}
ファイルサイズがゼロの場合は例外を起こすと思うので,気になる方は修正願いたい。
5. 参考文献
- tailコマンドの実装を見る。- Hatena Blog
- ソースコードリーディング(head,tailコマンド編) - Yahoo! JAPAN Tech Blog
- WindowsでLinuxの「tail -f」っぽいことをする方法 - Qiita
- LinuxとWindowsのコマンド比較 - Qiita
- WindowsでTailっぽいことをやる - Qiita
- windowsで"tail -f "する - Qiita
- コマンドメモ(個人用メモ) - Qiita
- CUIに苦手意識がある人でも使えるコマンド - Qiita
- PowerShell で「tail -f」を実現する。- Qiita
- C#:自作RingBufferクラスを使い簡易Tailコマンドを書いてみた - Qiita
- tailを実装する - Qiita
- なぜファイルの末尾に改行を入れたほうが良いのか - Qiita
- なぜファイル末尾に改行を入れるのか - Qiita