はじめに
C#プロジェクトにおいて、コードの品質を維持するためにコードメトリクスの監視は重要です。Visual Studio ではコードメトリクスを簡単に確認できますが、CI/CD パイプラインやコマンドラインからも自動的にチェックできるようにできたら便利かと思い設定方法を調査しました。
コードメトリクスとは
コードメトリクスは、コードの複雑さや保守性を数値化したものです。主な指標として以下があります:
- 循環的複雑度(Cyclomatic Complexity): 制御フローの複雑さ
- 継承の深さ(Depth of Inheritance): クラス階層の深さ
- クラス結合度(Class Coupling): クラス間の依存関係
- 保守容易性指数(Maintainability Index): 全体的な保守しやすさ
Microsoft の標準コードメトリクス警告を利用
Microsoft では、.NET Analyzer の一部としてコードメトリクスの警告を提供しています。これらを活用することで、手軽にコード品質をチェックできます。正直サードパーティーのツールに頼らないとダメかと思っていましたが簡単な方法がちゃんと用意されていました。
設定方法
1. .editorconfig ファイルによる設定
プロジェクトルートに.editorconfigファイルを作成し、以下のコードメトリクス警告を有効化します:
# コードメトリクス
dotnet_diagnostic.CA1501.severity = warning
dotnet_diagnostic.CA1502.severity = warning
dotnet_diagnostic.CA1505.severity = warning
dotnet_diagnostic.CA1506.severity = warning
閾値のカスタマイズ
デフォルトの閾値を変更したい場合は、プロジェクト内にCodeMetricsConfig.txtファイルを作成し、以下のように設定します。
CA1505: 50
CA1502: 25
CA1506: 40
上記の例では:
- CA1505(保守容易性指数)の閾値を 50 に設定
- CA1502(循環的複雑度)の閾値を 25 に設定
- CA1506(クラス結合度)の閾値を 40 に設定
そして作成したCodeMetricsConfig.txtを csproj ファイルに追加します。
<ItemGroup>
<AdditionalFiles Include="CodeMetricsConfig.txt" />
</ItemGroup>
CI での活用
これらの設定により、dotnet buildコマンド実行時に自動的にコードメトリクスがチェックされ、閾値を超えた場合は警告やエラーが出力されます。CI 上で下記コマンドを実行することで Warning がある場合はビルドを失敗させることが可能です。
dotnet build --no-incremental --warnaserror
CI/CD パイプラインでビルドを実行することで、コードメトリクスの監視を自動化できます。
設定でコードメトリクス違反をエラーに設定すればローカル環境でもビルドが通らないようにすることが可能です。
参考 保守容易度について
Github Copilot にお願いして保守容易度が異なるメソッドを4つ作成してもらいました。メソッド自体には何の意味もないですし、クラス結合数などによっても上下する値ではありますが閾値の参考までに。
保守容易度:92(そもそも保守がいらないレベルの簡単さ)
// 保守容易度が高いメソッド
// 単一責任、明確な命名、シンプルなロジック
public bool IsEven(int number)
{
return number % 2 == 0;
}
保守容易度:63(保守が容易)
// 保守容易度が中くらいのメソッド
// 複数の条件分岐があるが、まだ理解可能
public string GetUserStatus(int age, bool isActive, DateTime lastLogin)
{
if (age < 18)
{
return "未成年";
}
if (!isActive)
{
return "非アクティブ";
}
var daysSinceLogin = (DateTime.Now - lastLogin).Days;
if (daysSinceLogin > 30)
{
return "長期未ログイン";
}
else if (daysSinceLogin > 7)
{
return "最近未ログイン";
}
else
{
return "アクティブ";
}
}
保守容易度:45(コードはきれいではないが保守できるレベル)
// 保守容易度が低いメソッド
// 複雑なネストした条件分岐、多重責任、理解困難
public string ProcessComplexData(List<string> data, Dictionary<string, object> config, int mode)
{
var result = "";
var temp = new List<string>();
for (int i = 0; i < data.Count; i++)
{
if (data[i] != null)
{
if (mode == 1)
{
if (config.ContainsKey("filter"))
{
var filterValue = config["filter"].ToString();
if (data[i].Contains(filterValue))
{
if (config.ContainsKey("transform") && (bool)config["transform"])
{
temp.Add(data[i].ToUpper().Replace(" ", "_"));
}
else
{
temp.Add(data[i]);
}
}
}
else
{
temp.Add(data[i]);
}
}
else if (mode == 2)
{
if (data[i].Length > 5)
{
var parts = data[i].Split(' ');
for (int j = 0; j < parts.Length; j++)
{
if (parts[j].Length > 3)
{
if (config.ContainsKey("reverse") && (bool)config["reverse"])
{
temp.Add(new string(parts[j].Reverse().ToArray()));
}
else
{
temp.Add(parts[j]);
}
}
}
}
}
else
{
if (i % 2 == 0)
{
temp.Add(data[i].Substring(0, Math.Min(3, data[i].Length)));
}
}
}
}
result = string.Join(config.ContainsKey("separator") ? config["separator"].ToString() : ",", temp);
if (config.ContainsKey("prefix"))
{
result = config["prefix"].ToString() + result;
}
return result.Length > 100 ? result.Substring(0, 100) + "..." : result;
}
保守容易度:27(理解することが困難、正直触りたくないレベル)
// さらに保守容易度が低いメソッド
// 極度に複雑な制御フロー、例外処理の乱用、グローバル状態の変更
public object ExecuteBusinessLogic(object input, string operation, params object[] parameters)
{
object result = null;
var temp = new Dictionary<string, object>();
var flags = new bool[10];
var counter = 0;
try
{
if (input != null)
{
switch (operation?.ToLower())
{
case "process":
try
{
if (parameters?.Length > 0)
{
for (int x = 0; x < parameters.Length; x++)
{
try
{
var param = parameters[x];
if (param is int intParam)
{
if (intParam > 0)
{
for (int y = 0; y < intParam; y++)
{
counter++;
if (counter % 2 == 0)
{
if (x < flags.Length)
{
flags[x] = !flags[x];
}
try
{
temp[$"key_{x}_{y}"] = input.ToString() + "_" + counter;
}
catch
{
temp[$"error_{x}"] = "failed";
}
}
else
{
if (input is string strInput)
{
if (strInput.Length > y)
{
try
{
var ch = strInput[y];
if (char.IsLetter(ch))
{
temp[$"char_{x}_{y}"] = ch.ToString().ToUpper();
}
else if (char.IsDigit(ch))
{
temp[$"digit_{x}_{y}"] = int.Parse(ch.ToString()) * 2;
}
}
catch (Exception ex)
{
temp[$"exception_{x}_{y}"] = ex.Message.Substring(0, Math.Min(10, ex.Message.Length));
}
}
}
}
}
}
else if (intParam < 0)
{
for (int z = Math.Abs(intParam); z > 0; z--)
{
try
{
if (z % 3 == 0)
{
temp[$"neg_{x}_{z}"] = input?.GetHashCode() + z;
}
else if (z % 3 == 1)
{
temp[$"mod_{x}_{z}"] = (input?.ToString()?.Length ?? 0) * z;
}
else
{
temp[$"rem_{x}_{z}"] = DateTime.Now.Millisecond + z;
}
}
catch
{
// Intentionally swallow exception
continue;
}
}
}
}
else if (param is string strParam)
{
if (!string.IsNullOrEmpty(strParam))
{
var chars = strParam.ToCharArray();
for (int a = 0; a < chars.Length; a++)
{
for (int b = a + 1; b < chars.Length; b++)
{
if (chars[a] == chars[b])
{
temp[$"dup_{a}_{b}"] = chars[a];
break;
}
else if (Math.Abs(chars[a] - chars[b]) < 5)
{
temp[$"close_{a}_{b}"] = $"{chars[a]}{chars[b]}";
}
}
}
}
}
else
{
try
{
temp[$"unknown_{x}"] = param?.GetType()?.Name + "_" + param?.GetHashCode();
}
catch
{
temp[$"error_{x}"] = "unknown_error";
}
}
}
catch (Exception innerEx)
{
try
{
temp[$"inner_error_{x}"] = innerEx.GetType().Name;
}
catch
{
// Double exception handling - very bad practice
}
}
}
}
}
catch (Exception processEx)
{
temp["process_error"] = processEx.Message;
}
break;
case "analyze":
var analysis = new Dictionary<string, object>();
try
{
analysis["input_type"] = input?.GetType()?.Name ?? "null";
analysis["param_count"] = parameters?.Length ?? 0;
analysis["timestamp"] = DateTime.Now.Ticks;
if (parameters != null)
{
var types = parameters.Select(p => p?.GetType()?.Name ?? "null").ToArray();
analysis["param_types"] = string.Join("|", types);
}
temp["analysis"] = analysis;
}
catch
{
temp["analysis"] = "failed";
}
break;
default:
temp["error"] = "unknown_operation";
break;
}
// Side effect: modifying class state (if this were an instance variable)
counter += temp.Count;
if (temp.Count > 50)
{
result = temp.Take(50).ToDictionary(kvp => kvp.Key, kvp => kvp.Value);
}
else if (temp.Count > 0)
{
result = temp;
}
else
{
result = "no_data";
}
}
else
{
result = "null_input";
}
}
catch (Exception outerEx)
{
try
{
result = new Dictionary<string, object>
{
["outer_error"] = outerEx.Message,
["stack_trace"] = outerEx.StackTrace?.Substring(0, Math.Min(100, outerEx.StackTrace?.Length ?? 0)),
["counter"] = counter
};
}
catch
{
result = "catastrophic_failure";
}
}
finally
{
// Unnecessary finally block with potential side effects
GC.Collect(); // Very bad practice - forcing garbage collection
}
return result;
}
さらにコードが長く複雑になっていくといずれは 0 になります。
個人的には保守容易度が 30 を下回るとリファクタリング自体の難易度が高くなり時間がかかる印象です。そのため 50 ~ 30 くらいのタイミングでリファクタリングを行えば労力をかけすぎないで実施できるのではと思っています。
まとめ
Microsoft が提供する標準のコードメトリクス警告を活用することで、追加のツールを導入することなく簡単にコード品質を監視できます。CI/CD パイプラインに組み込むことで、チーム全体でコード品質の維持を自動化できるでしょう。
CI でも使用できるコードメトリクスを公式から入手できるのは C#の魅力です。
この記事が皆様のコーディングライフの助けになれば幸いです。
参考