Windowsのhead/tailコマンドをC#でささっと作る

# 0. はじめに

ある日突然，サイズがギガバイト級のテキストファイルを大量に処理することになった。とはいってもファイルの先頭および最後の一行だけを抽出するという簡単なお仕事である。

最初 PowerShell でスクリプトを作った。ファイル名 `$filename`，出力行数 `$line` とするとコア部分は下記のように一行で済む。

```powershell:head.ps1
Get-Content $filename -TotalCount $line
```

```powershell:tail.ps1
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` で一本化できるのは嬉しいことだ。

先頭から指定行数分を出力するコア処理部を以下に示すが，とても簡単である。

```c#:先頭から指定行数分を出力するコア処理部
for( int i = 0; i < output_line && !reader.EndOfStream; i++ ) {
	string	s = reader.ReadLine();
	Console.Out.WriteLine( s );
}
```

<details><summary><b>head コマンドの実装コードはコチラ</b></summary>

```c#:head.cs
//------------------------------------------------------------------------------
// テキストファイルの先頭の数行を表示します。
//------------------------------------------------------------------------------
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 );
	}
}
```

</details>

# 4. 次に tail コマンドを作る

## 4.1 ファイル末尾の改行コード問題

ファイルの末尾から検索して最初に見つけた改行コードの次のバイトが最終行の先頭位置である。ただし，ファイルによってはファイル末尾が改行コードになっている場合がある。その場合は最初の改行コードをスキップして，二番目に見つけた改行コードの次のバイトを最終行の先頭位置とする。

```plantuml
@startuml
<style>
root {
  margin: 0;
  padding: 0;
}
title {
  fontSize: 17;
  fontStyle: normal;
}
</style>
sprite FIGURE1 <svg width="500" height="120" viewbox="0 0 500 120">
<g fill="yellow">
<path d="M194 20 v32 h32 v-32" />
</g>
<g fill="black">
<path d="M18 20 h464 v1 h-464" />
<path d="M18 52 h464 v1 h-464" />
<path d="M34  20 v32 h1 v-32" />
<path d="M66  20 v32 h1 v-32" />
<path d="M98  20 v32 h1 v-32" />
<path d="M130 20 v32 h1 v-32" />
<path d="M162 20 v32 h1 v-32" />
<path d="M194 20 v32 h1 v-32" />
<path d="M226 20 v32 h1 v-32" />
<path d="M258 20 v32 h1 v-32" />
<path d="M290 20 v32 h1 v-32" />
<path d="M322 20 v32 h1 v-32" />
<path d="M354 20 v32 h1 v-32" />
<path d="M386 20 v32 h1 v-32" />
<path d="M418 20 v32 h1 v-32" />
<path d="M450 20 v32 h1 v-32" />
<path d="M482 20 v32 h1 v-32" />
</g>
<circle cx="243" cy="37" r="6" fill="none" stroke="red" />
<text x="226" y="72" fill="red" font-size="17">最終行の先頭位置</text>
<path d="M204 33 l4 12 h1 l-4 -12" fill="black" />
<text x="210" y="44" fill="black" font-size="16">n</text>
</svg>
sprite FIGURE2 <svg width="500" height="120" viewbox="0 0 500 120">
<g fill="yellow">
<path d="M130 20 v32 h32 v-32" />
<path d="M450 20 v32 h32 v-32" />
</g>
<g fill="black">
<path d="M18 20 h464 v1 h-464" />
<path d="M18 52 h464 v1 h-464" />
<path d="M34  20 v32 h1 v-32" />
<path d="M66  20 v32 h1 v-32" />
<path d="M98  20 v32 h1 v-32" />
<path d="M130 20 v32 h1 v-32" />
<path d="M162 20 v32 h1 v-32" />
<path d="M194 20 v32 h1 v-32" />
<path d="M226 20 v32 h1 v-32" />
<path d="M258 20 v32 h1 v-32" />
<path d="M290 20 v32 h1 v-32" />
<path d="M322 20 v32 h1 v-32" />
<path d="M354 20 v32 h1 v-32" />
<path d="M386 20 v32 h1 v-32" />
<path d="M418 20 v32 h1 v-32" />
<path d="M450 20 v32 h1 v-32" />
<path d="M482 20 v32 h1 v-32" />
</g>
<text x="162" y="72" fill="red"   font-size="17">最終行の先頭位置</text>
<circle cx="179" cy="37" r="6" fill="none" stroke="red" />
<path d="M140 33 l4 12 h1 l-4 -12" fill="black" />
<text x="146" y="44" fill="black" font-size="16">n</text>
<path d="M460 33 l4 12 h1 l-4 -12" fill="black" />
<text x="466" y="44" fill="black" font-size="16">n</text>
</svg>
title
<$FIGURE1>
<$FIGURE2>
図１　ファイル末尾の改行コード問題
end title
@enduml
```

## 4.2 標準入力から読み込む場合

標準入力がリダイレクトされている場合，いったん `StreamReader` の `ReadToEnd()` でストリームを一括して読み込む。

```c#:ストリームから一括で読み込む
var	reader = new StreamReader( Console.OpenStandardInput(), encoding );
string	buf = reader.ReadToEnd();
reader.Close();
```

文字列の末尾から改行コードを検索するようにした。

```c#:末尾から改行コードを検索
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 バイト）のバッファに読み込んで出力行数分の改行コードを検索するようにした。

```C#:当初案
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 ) );
```

:::note
mmap が実装される以前，初期の UNIX の tail コマンドはこんな感じのコードだったと推察する。
:::

## 4.4 ファイルから読み込む場合（改善案）

しかし最終的には `MemoryMappedFile` を用いるようにした。OS の仮想記憶（ページング）と融合した仕組みで，ファイルをマッピングした時点ではファイルの読み込むは発生せず，参照した領域のページ単位で自動的に読み込みが行われる。つまり面倒な処理を OS に任せたおかげで，コードは随分とシンプルになった。

```c#:改善案
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 実装コード

実装コードを以下に示す。標準入力がリダイレクトされているときとファイルから読み込む場合で処理を共通化したかったが，残念ながら自分の技術力では無理だった。

<details><summary><b>tail コマンドの実装コードはコチラ</b></summary>

```c#:tail.cs
//------------------------------------------------------------------------------
// テキストファイルの最後の数行を表示します。
//------------------------------------------------------------------------------
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 );
	}
}
```

</details>

:::note warn
ファイルサイズがゼロの場合は例外を起こすと思うので，気になる方は修正願いたい。
:::

# 5. 参考文献

- [tailコマンドの実装を見る。- Hatena Blog](https://syohex.hatenablog.com/entry/20110125/1295963309)
- [ソースコードリーディング(head,tailコマンド編) - Yahoo! JAPAN Tech Blog](https://techblog.yahoo.co.jp/tips/headtail/)
- [WindowsでLinuxの「tail -f」っぽいことをする方法 - Qiita](https://qiita.com/pokari_dz/items/52df50911bcbf9c5e513)
- [LinuxとWindowsのコマンド比較 - Qiita](https://qiita.com/my2k_/items/efcf3fd3eb63f2ab7989)
- [WindowsでTailっぽいことをやる - Qiita](https://qiita.com/ns777/items/2cdf5fff6f4ad0d1e023)
- [windowsで"tail -f "する - Qiita](https://qiita.com/kubalog/items/5ea36cc5a1199848e3e3)
- [コマンドメモ(個人用メモ) - Qiita](https://qiita.com/Ghost_node/items/3dfc9930542e7e6e4a7a)
- [CUIに苦手意識がある人でも使えるコマンド - Qiita](https://qiita.com/fu-ji/items/bc6d8483829b2dde3334)
- [PowerShell で「tail -f」を実現する。- Qiita](https://qiita.com/gtom7156/items/9db72f1cab7328c370a9)
- [C#:自作RingBufferクラスを使い簡易Tailコマンドを書いてみた - Qiita](https://qiita.com/gushwell/items/9e07156514d81d773640)
- [tailを実装する - Qiita](https://qiita.com/ymmtmdk/items/10cf80ec2fc7962ff9fc)
- [なぜファイルの末尾に改行を入れたほうが良いのか - Qiita](https://qiita.com/hamacccccchan/items/11c17c7412a5aeb2ad74)
- [なぜファイル末尾に改行を入れるのか - Qiita](https://qiita.com/Aseiide/items/e66be44c4b7972902063)