スポンサーリンク

バッチで,コーディング規約を守らせよう (全ソースコードをチェックして,ルール違反を自動検出)


コードの読みやすさ」は,非常に重要だ。


ソースコードが読みづらくなると,コードが「仕様を表現」しなくなる。


簡単にバグが混入され,埋もれてしまう。それに気付きもしなくなる。

保守や改良ができなくなる。

プロジェクトが行き詰まる。

デスマーチが始まる。


・・・


逆に,「読みやすいコード」でさえあれば,

どれほど多くのバグが混入しているとしても,容易にすばやく修正できる。

保守性が高いのである。


「読みやすいコード」は「仕様を明快に表現しているコード」なので,

他の人が読んだ時,仕様の誤解が起こらない。手をつけやすい。


「把握しやすいコード」は「変更しやすいコード」なので,プロジェクトがどんどん前に進む。

でも,それを確保するにはどうしたらよいのか?



「コードの読みやすさ」を確保するには


上級者は,誰に言われなくても,自然に「読みやすいコード」を書くことができる。


初心者を含めたプロジェクトの全員が「読みやすいコード」を書くようにするため,

通常,プロジェクトの最初に「コーディング規約」を作るだろう。



でも,時間の経過とともに,誰も規約を守らなくなる。


忙しくなるので,規約なんて忘れてしまう。


やっつけ仕事をするようになる。

デスマーチが始まる。

・・・

どうしたらよいか

このように,「コードの読みやすさ」はプロジェクトにおいて致命的に重要な要素であるにも関わらず,

コーディング規約はどうしても無視される,という事になる。


これは,仕方のない事だ。

人間はロボットではないので,自分がもともと持っていない習慣を

いちいち思い出しながらプログラミングする,ということはできない。


だから,対策として,

コーディング規約定義書を「実行可能ドキュメント」にする

という手を打てばいい。


もし静的型付け言語(Javaとか)であれば,EclipseのようなIDEが,自動的にコードフォーマットしてくれる。

コーディング規約に違反している個所を,ツールが自動的に知らせてくれる。

これも,どこでもやっているだろう。


しかし,動的型付け言語(Rubyとか)の場合は,どうしたらよいのか?

動的言語なので,コードの静的解析のための一般的なツールがないのだ。*1

そういう場合,デファクトのIDEも存在しなかったりする。


手動や目視でのチェックには限界がある。

スキルのある開発者がいつもソースコードをレビューできればよいが,それも時間的・コスト的に必ずしも余裕がなかったりする。

どうすればよいか。

ひとつの方法

一つの方法は,動的言語のソースコードに対して,

簡易な静的解析(チェック)を実行するようなバッチを自前で作ることだ。


以下のバッチを使えば,プロジェクト内で,

基本的なコーディング規約のチェックを自動化できる。

言語の種類は問わない。


サンプルとして,Ruby on RailsのWebアプリケーションに対するチェックを記述してみる。


main.js


// プロジェクト内のコードをチェック

new CodeChecker({
	// チェック対象のルートフォルダ
	target_path : "D:\\dev\\src\rails"
}).check({
	file_types : [
		"*.rb",
		"*.js"
	],
	max_file_length : 600, // 行数
	max_line_length : 120, // 行の長さ
	because : "行内文字数と行数が多過ぎないこと"
}).check({
	file_types : [
		"*.rhtml"
	],
	max_file_length : 200,
	max_line_length : 120,
	because : "行内文字数と行数が多過ぎないこと(ビューは画面設計を明示する仕方で簡潔に書くこと)"
}).check({
	file_types : [
		"*.rb"
	],
	limit_block_size : { // ブロックの長さを制限
		keyword : "def",
		max_line_length : 80
	},
	because : "Rubyで長すぎる関数を書かないこと"
}).check({
	file_types : [
		"*\\app\\models\\*.rb",
		"*\\app\\helpers\\*.rb",
		"*\\app\\views\\*.rhtml"
	],
	must_not_contain : "params", // 含んではいけないキーワード
	because : "HTTPパラメータの解析はコントローラ内で完結させること(paramsを使うな)"
})
;

コーディング規約が JavaScript (WSH/JScript) で書かれている。

これは実行可能なプログラムとして書かれているので,「実行可能ドキュメント」である。


CodeCheckerというオブジェクトを生成してから,チェックのルールを一つずつ,check() というメソッドの中に記述してゆく。

各ルールには,

  • file_types:検証対象のファイル名のパターン(拡張子とか)
  • 具体的な検証内容(行の長さとか)
  • because:検証の理由や目的,設計方針

が含まれている。



このスクリプトを実行するための起動バッチは以下。


execute.bat

@cscript //nologo execute.wsf


execute.wsf

<job>
	<script src=".\\code_checker.js"></script>
	<script src=".\\main.js"></script>
</job>


そして,要となる「CodeChecker」の中身は以下。


これらの4つのファイルを同一フォルダ内に設置し,起動バッチを実行すれば,チェックの結果がコマンドプロンプトに表示される。

(タブ区切りで出力されるので,リダイレクトすれば,Excelに表形式で貼り付けたりもできる。)


code_checker.js

/*
	コード内容をチェックするクラス
*/

var CodeChecker = function( init_hash ){
	// 初期化
	this._target_path = init_hash[ "target_path" ];
	
	// チェック実行に備える
	this._init();
};
CodeChecker.prototype = {
	// 探索対象のルートパス
	_target_path : null,
	
	// 全ファイルのリスト
	_all_files : {},
	
	
	// 初期化
	_init : function()
	{
		this._store_all_files_list_by_ext();
	}
	,
	
	
	// -------------- ファイル処理系 --------------
	
	
	// 全ファイルのリストを拡張子別に保持
	_store_all_files_list_by_ext : function()
	{
		this._all_files = this.get_all_files_list();
		
		// 登録結果をデバッグ
		//this._dump_all_files_list();
	}
	,
	
	// 全ファイルのファイルパスの配列を取得
	get_all_files_list : function()
	{
		var ws = WScript.CreateObject("WScript.Shell");
		ws.CurrentDirectory = this._target_path;
		var proc = ws.Exec("cmd.exe /c dir /A-D /s /b *");
		var res = proc.StdOut.ReadAll().split("\r\n");
		res.pop(); // 最後の行は空白
		
		return res;
	}
	,
	
	// 保持しているファイルリストをデバッグ用にダンプ
	_dump_all_files_list : function()
	{
		var all_files = this._all_files;
		for( var i = 0; i < all_files.length; i ++ )
		{
 			this.log( all_files[ i ] );
		}
	}
	,
	
	// 指定されたファイルタイプのファイルを全決定
	_specify_target_files : function( file_types )
	{
		var all_files = this._all_files;
		var target_files = [];
		for( var i = 0; i < all_files.length; i ++ )
		{
			for( var j = 0; j < file_types.length; j ++ )
			{
				var file_path = all_files[ i ];
				var file_type = file_types[ j ];
				
				// マッチするか
				if( this._match_file_type( file_path, file_type ) )
				{
					target_files.push( file_path );
						//this.log( file_path + "が" + file_type + "にマッチ" );
				}
			}
		}
		
		return target_files;
	}
	,
	
	// あるファイルパスがあるファイルタイプ指定文字列にマッチするか
	_match_file_type : function( file_path, file_type )
	{
		// ワイルドカード表記を正規表現評価用に変換
		var compiled_file_type = "^" 
			+ file_type
				.replace( /\\/g, "\\\\" )
				.replace( /\./g, "\\." )
				.replace( /\*/g, ".*" )
			+ "$"
		;
		
		if( file_path.match( compiled_file_type ) )
		{
			return true;
		}
		else
		{
			return false;
		}
	}
	,
	
	// ファイル内容を読み取り(UTF8)
	_read_file_content : function( file_path )
	{
		var adTypeText = 2; // テキスト
		var adReadAll = -1; // 全行

		var sr = new ActiveXObject("ADODB.Stream");
		sr.Type = adTypeText;
		sr.charset = "utf-8";

		sr.Open();
		sr.LoadFromFile( file_path );
		var str = sr.ReadText( adReadAll ) + "";

		sr.Close();
		
		return str;
	}
	,
	
	
	// ---------- チェック実行系 -----------
	
	
	// 指定された全ファイルに対してチェックを実行
	check : function( hash )
	{
		// 対象ファイルを決定
		var target_files = this._specify_target_files( hash["file_types"] );
		
		// 各ファイルごとにチェック
		for( var i = 0; i < target_files.length; i ++ )
		{
			var file_path = target_files[i];
			var file_content = this._read_file_content( file_path );
		
			this._check_one_file( file_path, file_content, hash )
		}
		
		// メソッドチェインを可能にする
		return this;
	}
	,
	
	// 1ファイルをチェック
	_check_one_file : function( file_path, file_content, hash )
	{
		// チェックタイプ別に実行

		// 行数
		if( hash["max_file_length"] )
		{
			this._check_max_file_length( file_path, file_content, hash["max_file_length"] );
		}
		
		// 行内文字数
		if( hash["max_line_length"] )
		{
			this._check_max_line_length( file_path, file_content, hash["max_line_length"] );
		}
		
		// ブロック長を制限
		if( hash["limit_block_size"] )
		{
			this._check_block_length( file_path, file_content, hash["limit_block_size"] );
		}
		
		// 禁止キーワード
		if( hash["must_not_contain"] )
		{
			this._check_forbidden_keyword( file_path, file_content, hash["must_not_contain"], hash["because"] );
		}
	}
	,
	
	// 行数をチェック
	_check_max_file_length : function( file_path, file_content, max_file_length )
	{
		var file_length = file_content.replace( /\r\n/g, "\n" ).split("\n").length;
		if( file_length > max_file_length )
		{
			this._check_err(
				file_path,
				"行数が多すぎる(" + file_length + "行)"
			);
		}
	}
	,
	
	// 行内文字数をチェック
	_check_max_line_length : function( file_path, file_content, max_line_length )
	{
		var lines = file_content.replace( /\r\n/g, "\n" ).split("\n");
		for( var i = 0; i < lines.length; i ++ )
		{
			var line = lines[i];
			if( line.length > max_line_length )
			{
				this._check_err(
					file_path,
					"行が長すぎる(" 
					+ ( i + 1 ) 
					+ "行目,"
					+ line.length
					+ "文字)\t"
					+ line
				);
			}
		}
		
	}
	,
	
	// ブロックのサイズをチェック
	_check_block_length : function( file_path, file_content, limit_info )
	{
		var lines = file_content.replace( /\r\n/g, "\n" ).split("\n");
		var block_keyword   = limit_info["keyword"];
		var max_line_length = limit_info["max_line_length"];
		
		var reg_keyword = "^[ \t]*" 
			+ block_keyword 
			+ "[^a-zA-Z0-9_]"
		;
		
		// 全行を順番にスキャンする
		var block_size_counter = 0;
		var inside_block_flag = false;
		var block_init_code = "";
		var block_init_line_num = 0;
		for( var i = 0; i < lines.length; i ++ )
		{
			var line = lines[i];
			
			// ブロックの始まりに来たか
			if( line.match( reg_keyword ) )
			{
				// カウンターを初期化
				inside_block_flag = true;
				block_size_counter = 0;
				block_init_code = line;
				block_init_line_num = i + 1;
			}
			
			// ブロック内ならば
			if( inside_block_flag )
			{
				// 1行カウント
				block_size_counter ++;
			
				// 超過していないか
				if( block_size_counter > max_line_length )
				{
					this._check_err(
						file_path,
						block_keyword
						+ "ブロックが長すぎる(" 
						+ block_init_line_num
						+ "行目,"
						+ max_line_length
						+ "行を超過)\t"
						+ block_init_code
					);
					
					// いったん超過が検出されたら,次のブロック始まりまでは無視
					inside_block_flag = false;
					block_size_counter = 0;
				}
			}
		}
		
	}
	,
	
	// 禁止文字列をチェック
	_check_forbidden_keyword : function( file_path, file_content, forbidden_keyword, reason )
	{
		var lines = file_content.replace( /\r\n/g, "\n" ).split("\n");
		var reg_keyword = "^[^a-zA-Z0-9_]*" 
			+ forbidden_keyword 
			+ "[^a-zA-Z0-9_]"
		;
		
		for( var i = 0; i < lines.length; i ++ )
		{
			var line = lines[i];
			if( line.match( reg_keyword ) )
			{
				this._check_err(
					file_path,
					reason // 理由を表示する
					+ "("
					+ ( i + 1 ) 
					+ "行目,\""
					+ forbidden_keyword
					+ "\"\t"
					+ line
				);
			}
		}
		
	}
	,
	
	
	// ---------- その他 -----------
	
	
	// チェック結果のエラー内容を表示
	_check_err : function( file_path, err_msg )
	{
		// タブ区切りで表示して,Excelに表形式で貼り付けられるようにする
		this.log(
			""
			+ file_path
			+ "\t"
			+ err_msg
		);
	}
	,
	
	log : function( s )
	{
		WScript.Echo( s );
	}
};


もし,検証可能なルールを増やしたいのであれば,

上記コード内の check() メソッドの中に,独自の新規ルールを記述して拡張することができる。



これで,「コーディング規約に遵守している事の検査」を自動化できる。

実装工程の品質チェックのうち「保守性」の検査が,自動化できるのである。


このバッチを,毎日自動的に実行されるようにしておけば,

知らない間に,キャッチアップできないようなスパゲッティなコードが大量発生していた!!!

という,悪寒を生む事態を避けられる。


長いメソッドの発生は抑止され,

チームのメンバは常にリファクタリングを促され,

コードの「危険な匂い」は自動的に通知され,

コードはクリーンな状態に保たれる。


プロジェクトは,前に進み続ける事ができるのだ。


補足

もちろん,単体テストをたくさん書くのも良い習慣だ。

テストによって「実行時の挙動の正確さ」を確保できる。

メソッドの長さは「恐らく,わりと保守しやすいであろう程度の長さ」に保たれる。


でも,それだけではコードの「読みやすさ」を担保しきれない,という面もある。

今回のバッチをプラスアルファとして使えば,コードの読みやすさは,定性的にではなく,「定量的に保証」される。


※チェックのルールは,ある程度余裕をもったルールにしておくとよいだろう。



 

*1:「プロダクティブ・プログラマ」p145,「動的言語の解析」の項を参照。rcov, Saikuro, flogなどが紹介されている