i-Vinci TechBlog
株式会社i-Vinciの技術ブログ

インポートツールで考えるソフトウェアの変更容易性

インポートツールで考えるソフトウェアの変更容易性

概要

こんにちは、コロナ禍でガッツリ太った下位です。
体脂肪率削減のため、日々 FitBoxing に励んでいます。

今回は変更容易性(EoC:Ease of Change)についての話です。

変更容易性はざっくり言ってしまうと、作成したモジュールがどれだけ変更に強いか、しやすいかを示す指標です。
製造というより保守観点で重要になります。

変更容易性の高いプログラムは保守のしやすいプログラムに通じます。
本記事ではそれらの内容を説明できたら良いなと考えます。

ツール概要

今回はテキストファイル読み込みを行う、データインポートツールの製造をテーマとしました。
但し DB 制御の観点は本記事では重要視しないため、取り扱いません。

【主な要件】

  • Console アプリを想定
  • テキストファイル(csv 等)を読み込む
    • csv:フィールド 2 つの構成を想定(id, data)
  • ファイル形式の検証は不要(csv であれば、引用符、データ内改行の考慮不要)
  • 読み込んだデータは標準出力へ出力する

ツールの基本形

とりあえず、要件に従った製造であれば下記のようなイメージでしょうか。
あえて変更容易性を意識せず、単発利用のツールを想定しています。

public class Program {

  public static void main (String[] args) {
    // ファイルはコマンドライン引数から取得
    Path filePath = Paths.get(String.Format("resources/%s", args[0]));
    // ファイル内のレコードを取得
    List<String[]> dataList = Files.lines(filePath).map(d -> {
      // ①読み込んだレコードを配列として返却(csv想定)
      return d.split(",");
    }).collect(Collectors.toList());

    // 取得したデータを標準出力に表示
    dataList.forEach(d -> {
      // ②整形した文字列を出力
      System.out.printf("id:%s, data:%s\n", d[0], d[1]);
    });
  }
}

Let's 改修

それでは改修です。
偉い人はいいました。「json にも対応してほしいな」と。

上記ソースに対する追加であれば、こんなイメージでしょうか。

既存処理に対し、追加処理を分岐構文で対応しました。

public class Program {

  public static void main (String[] args) {

    // ファイルはコマンドライン引数から取得
    Path filePath = Paths.get(String.Format("resources/%s", args[0]));

    // 拡張子取得
    String[] pathParams = filePath.toString().split(".");
    String extension = (pathParams.length <= 0)
      ? ""
      : pathParams[pathParams.length - 1];

    // CSV読み込み
    if (extension.equals("csv")) {
      // ファイル内のレコードを取得
      List<String[]> dataList = Files.lines(filePath).map(d -> {
        // 読み込んだレコードを配列として返却(csv想定)
        return d.split(",");
      }).collect(Collectors.toList());

      // 取得したデータを標準出力に表示
      dataList.forEach(d -> {
        // 整形した文字列を出力
        System.out.printf("id:%s, data:%s\n", d[0], d[1]);
      });

    // JSON読み込み
    } else if (extension.equals("json")) {
      // 何らかのパーサーを使う
      List<HogeClass> dataList = JsonParser.Parse(Files.readString(filePath));

      // 取得したデータを標準出力に表示
      dataList.forEach(d -> {
        // 整形した文字列を出力
        System.out.printf("id:%s, data:%s\n", d.id, d.data);
      });
    }
  }
}

1 メソッドの中で CSV、JSON の 2 つのファイル対応をしたため、main メソッドが肥大化してしまいました。
更に対応するフォーマットが増えた際、同じような改修をしてしまうとプログラムの複雑度が上がってしまいそうです。

何が問題なのか

上記プログラムを参照した際、下記の懸念が挙げられます。

  • main メソッドの責任範囲が広い
    • ファイル読込〜解析の一連の処理を全て受け持っている
    • 複数のファイル形式を意識してしまっている。
  • CSV,JSON の解析処理で共通する処理が散見される(※実際コピペで作成)
    • ファイル解析
    • 標準出力

main メソッドの中に処理が包括されており、とても見通しが悪くなってしまいました。
今は要件が少ないのでなんとでもなりますが、この先の拡張を考えると地獄が見えてしまいます。
「次は tsv、固定長対応ね。」なんて言われたら else if が更に増えそうな予感がします。

また、何かしらの改修を加えるたびに main メソッドへの変更が発生するため、全機能のテストを行わなければなりません。
これでは流石に保守をするのも大変です。

改修するのが開発者本人であればまだ救いが有りますが、保守というものは要員の流動が激しいものです。

大抵開発者はプロジェクトの節目で異動し、プログラムは後任に引き継がれます。
上記プログラムの驚異に晒されるのは貴方ではない誰かです。
ギリギリのバランスで構築されたプログラム、自信を持って後任に引き継げるでしょうか。

ファイル読み込み処理のモジュール分離

上記で改修したプログラムを元にリファクタリングしてみましょう。
今回は「ファイルを読み込む」点の抽象化が利用できそうです。

モデルクラス作成

まずは 1 データを表すモデルクラスを作成します。
モデルクラスを用意することで、ファイル読み込みによるデータのマッピングを抽象化する効果が期待できます。
共通のデータフォーマットを扱うのであれば、データモデルクラスを作ればまず間違いないです。

/**
 * データモデル
**/
public class FooModel {
  private String id;
  private String data;

  public FooModel(String id, String data) {
    this.id = id;
    this.data = data;
  }

  public String getId() {
    return id;
  }

  public String getData() {
    return data;
  }

  // 標準出力に利用するデータの整形はtoStringをoverrideしましょう。
  // 呼び出し元で出力時のデータ整形処理が不要となります。
  public override String toString() {
    return String.format("id:%s, data:%s", id, data);
  }
}

CSV 読込処理クラス作成

CSV ファイルの解析クラスを作ります。
着目点は先ほど作成したデータモデルクラスに対応した List を返却するところです。

ついでに interface も使って抽象化もしてしまいましょう。

// ファイル読み込み処理のinterface
public interface ResourceReader {
  // 実装クラスで必要なファイル読み込み処理
  // Generics指定のデータモデルはinterfaceを使って更に抽象化できると利便性があがります。
  List<FooModel> readResource(String filePath);
}

// CSVの解析クラス
public class CsvReader implements ResourceReader {
  // ファイルを読み込んで、データモデルのListを返却するところが重要です。
  public List<FooModel> readResource(String filePath) {
    return Files.lines(filePath).map(d -> {
      // 読み込んだレコードを配列として返却(csv想定)
      String[] dataParams = d.split(",");
      return new FooModel(dataParams[0], dataParams[1]);
    }).collect(Collectors.toList());
  }
}

// jsonの解析クラス
public class JsonReader implements ResourceReader {
  // 実装イメージはCSVと同様です。
  public List<FooModel> readResource(String filePath) {
    // 何らかのパーサーを使う
    List<FooModel> dataList = JsonParser.Parse(Files.readString(filePath));
    return dataList;
  }
}

改修リベンジ

作成したクラスを元に、先程のプログラムを修正しました。
いかがでしょうか。先程のモジュールより大分見通しが良くなったのではないでしょうか。

ファイル解析処理を個別クラスに分離させたため、対応ファイルが増えたとしても解析クラスを追加するだけで対応が終わります。
また、main メソッドへの変更は発生しません。
この作りであれば改修にもそんなに工数がかからなさそうです。これは変更容易性が高いといえます。

既存解析処理を改修した場合でも、改修クラスの再テストだけで検証が完了します。
保守観点では、如何に既存処理への影響を抑えつつ改修するかを問われるため、変更範囲の極小化は非常に重要です。

public class Program {

  public static void main (String[] args) {
    // ファイルはコマンドライン引数から取得
    Path filePath = Paths.get(String.Format("resources/%s", args[0]));

    // 拡張子取得
    String[] pathParams = filePath.toString().split(".");
    String extension = (pathParams.length <= 0)
      ? ""
      : pathParams[pathParams.length - 1];

    // 拡張子に応じたファイル解析インスタンスを取得
    ResourceReader instance = getInstance(extension);

    // ファイル読み込みの結果を取得
    // mainメソッドでファイル構造を意識していないことが重要です
    List<FooModel> dataList = instance.readResource(filePath);

    // 標準出力処理
    dataList.forEach(d -> System.out.println(d));
  }

  // ファイル拡張子に応じたデータ解析クラスのインスタンスを生成します
  // 解析クラスにinterfaceを用いたため、異なるクラス定義を共通して返却することが可能となります。
  private ResourceReader getInstance(String fileExtenstion) {
    if (fileExtenstion.equals("csv")) {
      return new CsvReader();
    } else if (fileExtenstion.equals("json")) {
      return new JsonReader();
    } else {
      throw new IllegalArgumentException("サポート対象外の拡張子が渡されました。");
    }
  }
}

固定長に対応してみる

ついでに固定長ファイルの対応をしてみましょう。
書式は 1 要素 5byte の、計 10byte としておきましょうか。

# 固定長データイメージ
  001dataA
  002dataB

改修手順は CSV と同じです。
解析クラスを作成して、インスタンスの生成処理を加えるだけ、になります。

// 固定長の解析クラスを用意して...
public class FlatReader implements ResourceReader {
  public List<FooModel> readResource(String filePath) {
    return Files.lines(filePath).map(d -> {
      // 固定長の桁数に応じて切り出し
      return new FooModel(d.substring(0, 5), d.substring(5, 10));
    }).collect(Collectors.toList());
  }
}

// Program.javaのインスタンス生成メソッドに分岐を追加するだけ!
private ResourceReader getInstance(String fileExtenstion) {
  if (fileExtenstion.equals("csv")) {
    return new CsvReader();
  } else if (fileExtenstion.equals("json")) {
    return new JsonReader();
  // ★固定長向けの解析クラスインスタンス生成処理★
  } else if (fileExtenstion.equals("dat")) {
    return new FlatReader();
  } else {
    throw new IllegalArgumentException("サポート対象外の拡張子が渡されました。");
  }
}

とても簡単ですね。素晴らしい。(自画自賛)

まとめ

ツール作成を通して保守観点を意識した製造とリファクタリングをしてみました。
同じ仕様のプログラムでも、変更容易性を意識すると大きく作りが変わることを示せたと考えます。

普段からリファクタリングに手を出せるとは限らないですが、機会があれば是非リファクタリングに挑んでみてください。
保守工程に入ったとき、そのプログラムは光り輝いて見えるはずです。(改修難易度的な意味で)