カプセル化による処理の分割から始めよう
はじめに
対象読者
- 1クラスに全部の処理を実装している人
- カプセル化という単語は聞いたことがあるけど、実装するにあたり何をすればいいか分からない
結論
構文的なカプセル化による処理の分割は、基本的に以下の流れで進めます。
- 外部との通信処理を分離する: API呼び出しやデータベースアクセスなど、外部システムとのやり取りを専用クラスに切り出す
- データとその操作を一緒にまとめる: 関連するデータとそれを扱う処理を同じクラスにカプセル化する
- メイン処理を整理する: 各専用クラスを組み合わせて、メイン処理をシンプルで理解しやすくする
これにより、以下のメリットを得られます。
- 可読性の向上: 各クラスが小さくなり、目的の処理を見つけやすくなる
- 保守性の向上: 特定の機能の変更が他の部分に影響を与えにくくなる
- 再利用性: 独立したクラスを他のプロジェクトでも活用できる
この記事では、実際のコード例を使ってこれらの手順を具体的に説明していきます。
カプセル化とは
カプセル化とはオブジェクト指向設計の一つで、関連するデータと処理を一つのクラスにまとめ、外部からの直接アクセスを制御することで、オブジェクトの内部実装を隠蔽する技術です。
この記事では特に「処理の分割」の観点からカプセル化を捉えます。
つまり、関連する処理とデータを適切にグループ化して別々のクラスに分割することで、コードの可読性と保守性を向上させることを目的とします。
何故クラスごとに処理を分けるのか?
関数単位での分割だけでは不十分
処理内容ごとにprivate関数に切り出して分割する方法もありますが、これだけでは以下のような問題が残ります。
- 全ての関数が同じクラス内に存在するため、クラス全体の責務が不明確
- 関連のない処理が同じクラス内に混在し、コードの理解が困難
- 機能追加時にどのクラスに実装すべきかが分からない
1クラスにすべて処理を書くことによるデメリット
1クラスにすべて処理を書くことにより、以下のデメリットがあります。
可読性の低下
関連のない処理が混在するため、読む必要のあるコードの量が増える。
これにより、目的の処理にたどり着くまでのスクロールの回数が増えて、目的の処理を探すのが困難になる。
保守性の低下
各処理が密結合しているため、一つの変更が他の処理に予期しない影響を与える可能性がある。
これにより、修正・変更が入る際に、影響範囲が把握しづらくなる。
再利用性の低下
必要な処理と不要な処理が同じクラスに含まれているため、不要な依存関係も一緒に持ち込むことになる。
これにより、特定の処理だけを他の場所で使いたい場合でも、クラス全体を参照する必要がある。
100行程度の機能であれば上記のような影響は少ないですが、実際の業務では1機能で数千行ものコードを書くことがあります。
そのようなケースでクラスが分割されていないと、コードレビューや修正・変更時のコード確認において、クラスが分割されている場合に比べて内容の把握に時間を費やすことになります。
クラスごとに処理を分けるメリット
逆に複数のクラスに処理を分割することで、以下のメリットを得られます。
可読性の向上
関連のない処理が混在しないため、読む必要のあるコードの量が減る。
これにより、特定の機能の理解に必要な情報のみを確認しやすくなる。
変更の局所化
各クラスが独立しているため、一つのクラスの変更が他のクラスの動作に影響することが少ない。
これにより、特定の機能の修正を行う際に他の機能に影響を与えにくくなる。
再利用性の向上
特定の機能だけを取り出して使えるため、不要な依存関係を持ち込まずに済む。
これにより、他の場所でも利用しやすくなる。
実際にやってみよう
前提
以下の機能を持ったバッチを作るとします。
- 引数に指定された郵便番号の値を基にzipcloudの郵便番号検索のREST APIを利用して住所を検索する。
- レスポンスの内容を取得し、該当する都道府県名(
address1)、市区町村名(address2)、町域名(address3)を半角スペースで区切ったものを全てコンソールに出力する。
コード
using System;
using System.Net.Http;
using System.Text.Json;
using System.Threading.Tasks;
public class Program
{
public static async Task Main(string[] args)
{
if (args.Length == 0)
{
Console.WriteLine("郵便番号を引数に指定してください。");
return;
}
string zipCode = args[0];
// API呼び出し
using var httpClient = new HttpClient();
string url = $"https://zipcloud.ibsnet.co.jp/api/search?zipcode={zipCode}";
try
{
HttpResponseMessage response = await httpClient.GetAsync(url);
response.EnsureSuccessStatusCode();
string jsonResponse = await response.Content.ReadAsStringAsync();
var apiResponse = JsonSerializer.Deserialize<ApiResponse>(jsonResponse);
if (apiResponse?.results != null && apiResponse.results.Length > 0)
{
foreach (var result in apiResponse.results)
{
string address = $"{result.address1} {result.address2} {result.address3}";
Console.WriteLine(address);
}
}
else
{
Console.WriteLine("該当する住所が見つかりませんでした。");
}
}
catch (Exception ex)
{
Console.WriteLine($"エラーが発生しました: {ex.Message}");
}
}
}
public class ApiResponse
{
public string message { get; set; }
public AddressResult[] results { get; set; }
public int status { get; set; }
}
public class AddressResult
{
public string address1 { get; set; } // 都道府県名
public string address2 { get; set; } // 市区町村名
public string address3 { get; set; } // 町域名
public string kana1 { get; set; }
public string kana2 { get; set; }
public string kana3 { get; set; }
public string prefcode { get; set; }
public string zipcode { get; set; }
}
この1クラスに全て実装したコードをクラス単位で分割していきます。
1. API呼び出しを別クラスへ切り出す
まず、API呼び出し処理を別クラスに切り出します。
APIの呼び出し~レスポンスの取得を行い、返却するクラスを追加します。
using System;
using System.Net.Http;
using System.Text.Json;
using System.Threading.Tasks;
public class ZipCloudApiClient
{
private readonly HttpClient _httpClient;
public ZipCloudApiClient()
{
_httpClient = new HttpClient();
}
public async Task<ApiResponse> SearchAddressByZipCodeAsync(string zipCode)
{
string url = $"https://zipcloud.ibsnet.co.jp/api/search?zipcode={zipCode}";
try
{
HttpResponseMessage response = await _httpClient.GetAsync(url);
response.EnsureSuccessStatusCode();
string jsonResponse = await response.Content.ReadAsStringAsync();
return JsonSerializer.Deserialize<ApiResponse>(jsonResponse);
}
catch (Exception ex)
{
throw new Exception($"API呼び出しでエラーが発生しました: {ex.Message}", ex);
}
}
public void Dispose()
{
_httpClient?.Dispose();
}
}
また、メインクラスもZipCloudApiClientを呼び出すよう修正します。
using System;
using System.Threading.Tasks;
public class Program
{
public static async Task Main(string[] args)
{
if (args.Length == 0)
{
Console.WriteLine("郵便番号を引数に指定してください。");
return;
}
string zipCode = args[0];
// API呼び出し用クラスのインスタンスを初期化
var apiClient = new ZipCloudApiClient();
try
{
// ZipCloudApiClientを使用してAPI呼び出し
var apiResponse = await apiClient.SearchAddressByZipCodeAsync(zipCode);
if (apiResponse?.results != null && apiResponse.results.Length > 0)
{
foreach (var result in apiResponse.results)
{
string address = $"{result.address1} {result.address2} {result.address3}";
Console.WriteLine(address);
}
}
else
{
Console.WriteLine("該当する住所が見つかりませんでした。");
}
}
catch (Exception ex)
{
Console.WriteLine($"エラーが発生しました: {ex.Message}");
}
finally
{
apiClient.Dispose();
}
}
}
この変更により、以下のメリットが得られます。
- 明確な役割分担: API通信に関する処理のみが1クラスに定義される
- 再利用性: 他の場所でも同じAPI呼び出し処理を使える
- 変更の局所化: API仕様が変更されても、このクラスのみを修正すればよい
2. 住所情報とそれを扱う処理をデータクラスとして切り出す
APIのレスポンスには様々な情報が含まれていますが、今回は都道府県名(address1)、市区町村名(address2)、町域名(address3)しか利用しません。
そのため、レスポンスから利用する値だけを取り出して管理するクラスを追加します。
public class AddressInfo
{
private string Prefecture { get; }
private string City { get; }
private string Town { get; }
public AddressInfo(AddressResult addressResult)
{
Prefecture = addressResult.address1 ?? "";
City = addressResult.address2 ?? "";
Town = addressResult.address3 ?? "";
}
public string GetFormattedAddress()
{
return $"{Prefecture} {City} {Town}";
}
}
さらに、APIクライアントの戻り値も業務に適した形に変更できます。
現在はApiResponseを返していますが、メインクラスで毎回AddressInfoへの変換処理を行っています。
この変換処理をAPIクライアント内で行うことで、メインクラスをよりシンプルにできます。
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Text.Json;
using System.Threading.Tasks;
public class ZipCloudApiClient
{
private readonly HttpClient _httpClient;
public ZipCloudApiClient()
{
_httpClient = new HttpClient();
}
// 戻り値をApiResponseからList<AddressInfo>に変更
public async Task<List<AddressInfo>> SearchAddressByZipCodeAsync(string zipCode)
{
string url = $"https://zipcloud.ibsnet.co.jp/api/search?zipcode={zipCode}";
try
{
HttpResponseMessage response = await _httpClient.GetAsync(url);
response.EnsureSuccessStatusCode();
string jsonResponse = await response.Content.ReadAsStringAsync();
var apiResponse = JsonSerializer.Deserialize<ApiResponse>(jsonResponse);
// APIクライアント内でAddressInfoへの変換を実行
if (apiResponse?.results != null && apiResponse.results.Length > 0)
{
return apiResponse.results
.Select(result => new AddressInfo(result))
.ToList();
}
// 結果が見つからない場合は空のリストを返す
return new List<AddressInfo>();
}
catch (Exception ex)
{
throw new Exception($"API呼び出しでエラーが発生しました: {ex.Message}", ex);
}
}
public void Dispose()
{
_httpClient?.Dispose();
}
}
元のクラスからは追加したクラスのインスタンスを生成するようにします。
using System;
using System.Threading.Tasks;
public class Program
{
public static async Task Main(string[] args)
{
if (args.Length == 0)
{
Console.WriteLine("郵便番号を引数に指定してください。");
return;
}
string zipCode = args[0];
var apiClient = new ZipCloudApiClient();
try
{
// 戻り値がList<AddressInfo>に変更されたため、変数名も変更
var addressInfoList = await apiClient.SearchAddressByZipCodeAsync(zipCode);
// ApiResponseではなくList<AddressInfo>を使用
if (addressInfoList.Count > 0)
{
foreach (var addressInfo in addressInfoList)
{
// フォーマット処理をAddressInfoクラスのメソッドに委譲
Console.WriteLine(addressInfo.GetFormattedAddress());
}
}
else
{
Console.WriteLine("該当する住所が見つかりませんでした。");
}
}
catch (Exception ex)
{
Console.WriteLine($"エラーが発生しました: {ex.Message}");
}
finally
{
apiClient.Dispose();
}
}
}
この変更により、以下のメリットが得られます。
-
メインクラスの簡素化: レスポンス内容を
List<AddressInfo>に変換する処理がAPIクライアント内に隠蔽され、メインクラスがよりシンプルになった -
カプセル化の向上: APIの内部構造(
ApiResponse、AddressResult)がメインクラスから隠蔽された -
再利用性の向上: 他の場所でこのAPIクライアントを使う際も、すぐに
List<AddressInfo>として結果を受け取れる
3. 最終的な構成
最終的なクラス構成は以下のようになります。
ZipCloudApiClient.cs
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Text.Json;
using System.Threading.Tasks;
public class ZipCloudApiClient
{
private readonly HttpClient _httpClient;
public ZipCloudApiClient()
{
_httpClient = new HttpClient();
}
public async Task<List<AddressInfo>> SearchAddressByZipCodeAsync(string zipCode)
{
string url = $"https://zipcloud.ibsnet.co.jp/api/search?zipcode={zipCode}";
try
{
HttpResponseMessage response = await _httpClient.GetAsync(url);
response.EnsureSuccessStatusCode();
string jsonResponse = await response.Content.ReadAsStringAsync();
var apiResponse = JsonSerializer.Deserialize<ApiResponse>(jsonResponse);
if (apiResponse?.results != null && apiResponse.results.Length > 0)
{
return apiResponse.results
.Select(result => new AddressInfo(result))
.ToList();
}
return new List<AddressInfo>();
}
catch (Exception ex)
{
throw new Exception($"API呼び出しでエラーが発生しました: {ex.Message}", ex);
}
}
public void Dispose()
{
_httpClient?.Dispose();
}
}
AddressInfo.cs
public class AddressInfo
{
private string Prefecture { get; }
private string City { get; }
private string Town { get; }
public AddressInfo(AddressResult addressResult)
{
Prefecture = addressResult.address1 ?? "";
City = addressResult.address2 ?? "";
Town = addressResult.address3 ?? "";
}
public string GetFormattedAddress()
{
return $"{Prefecture} {City} {Town}";
}
}
Program.cs
using System;
using System.Threading.Tasks;
public class Program
{
public static async Task Main(string[] args)
{
if (args.Length == 0)
{
Console.WriteLine("郵便番号を引数に指定してください。");
return;
}
string zipCode = args[0];
var apiClient = new ZipCloudApiClient();
try
{
var addressInfoList = await apiClient.SearchAddressByZipCodeAsync(zipCode);
if (addressInfoList.Count > 0)
{
foreach (var addressInfo in addressInfoList)
{
Console.WriteLine(addressInfo.GetFormattedAddress());
}
}
else
{
Console.WriteLine("該当する住所が見つかりませんでした。");
}
}
catch (Exception ex)
{
Console.WriteLine($"エラーが発生しました: {ex.Message}");
}
finally
{
apiClient.Dispose();
}
}
}
ApiResponse.cs / AddressResult.cs(共通モデル)
public class ApiResponse
{
public string message { get; set; }
public AddressResult[] results { get; set; }
public int status { get; set; }
}
public class AddressResult
{
public string address1 { get; set; } // 都道府県名
public string address2 { get; set; } // 市区町村名
public string address3 { get; set; } // 町域名
public string kana1 { get; set; }
public string kana2 { get; set; }
public string kana3 { get; set; }
public string prefcode { get; set; }
public string zipcode { get; set; }
}
カプセル化によるメリット
カプセル化による処理分割を行う前と後を比較すると、以下の変化が見られます。
| 観点 | Before | After |
|---|---|---|
| クラス構成 | 全ての処理が1つのProgramクラスに集中(API呼び出し、レスポンス処理、出力処理) |
各クラスが明確な責務を持つ ・ ZipCloudApiClient: API通信専用・ AddressInfo: 住所情報の管理専用・ Program: アプリケーションの制御専用 |
| 責務の明確さ | 何をするクラスなのかが不明確 | 各クラスの役割が明確に分離されている |
| 変更時の影響範囲 | API仕様変更時にProgramクラス全体を理解する必要がある |
API仕様が変更されてもZipCloudApiClientのみを修正すればよい |
| 再利用性 | 他の場所で住所検索機能を使いたい場合、Programクラス全体をコピーする必要がある |
ZipCloudApiClientやAddressInfoを同一プロジェクトの他機能や他のプロジェクトでも使える |
| 可読性 | 1つのファイルに全ての処理が含まれ、目的の処理を見つけにくい | 各ファイルが小さくなり、目的の処理を見つけやすい |
具体的な改善例
| シナリオ | Before | After |
|---|---|---|
| API仕様変更への対応 |
Programクラス全体を理解してから修正 |
ZipCloudApiClientのみを理解すれば修正可能 |
| 機能の再利用 | 他のプロジェクトで住所検索機能を使うにはProgramクラス全体をコピーする必要がある |
ZipCloudApiClientクラスのみをコピーすれば住所検索機能を再利用できる |
別クラスへ切り出す際のポイント
処理や値を別クラスに切り出す際は、以下の点を意識しましょう。
1. 各クラスは一つの明確な役割のみを持つようにする
各クラスは一つの明確な責務のみを持つようにします。これを単一責任の原則と呼びます。
-
良い例:
ZipCloudApiClientはAPI通信のみを担当 - 悪い例: API通信とデータ変換と出力処理を同じクラスで行う
2. メンバ変数とメソッドの関係を確認する
クラス内で実装しているメソッドが利用する情報のみがメンバ変数に含まれているかを確認します。
-
良い例:
AddressInfoクラスのGetFormattedAddressメソッドは、Prefecture、City、Townのみを使用 - 悪い例: 使わないメンバ変数がクラス内に存在している
3. データとそれを操作する処理をセットで考える
関連するデータとそれを操作する処理は同じクラスにまとめます。
-
良い例:
AddressInfoクラスに住所データ(Prefecture、City、Town)とフォーマット処理(GetFormattedAddress)をセット - 悪い例: データだけのクラスと処理だけのクラスに分離
4. 外部依存を明確にする
クラスが外部に依存する部分を明確にし、必要に応じて注入可能にします。
-
良い例:
ZipCloudApiClientのコンストラクタでHttpClientを初期化 - 改善案: 依存性注入で外部サービスへの依存を管理しやすくする
5. クラス名から責務が分かるようにする
クラス名を見ただけで、そのクラスが何をするのかが分かるようにします。
-
良い例:
ZipCloudApiClient(ZipCloud APIのクライアント)、AddressInfo(住所情報) -
悪い例:
Helper、Utility、Managerなどの汎用的な名前
カプセル化以外のオブジェクト指向設計
この記事では構文的なカプセル化による修正に焦点を絞っていますが、他にも様々なオブジェクト指向設計のノウハウが存在します。
「良いコード/悪いコードで学ぶ設計入門」という書籍にカプセル化を含めたオブジェクト指向設計の実践的なノウハウが記載されているので、気になった方はこの書籍を読むことから始めるのをおすすめします。
Discussion