Javaプログラミング能力認定試験課題プログラムのリファクタリングレポート(その1)

昨日書いたSI業界(日本)のJavaプログラマーにはオブジェクト指向より忍耐力が求められている? - 達人プログラマーを目指してが予想以上に大きな反響があり驚いています。特に、あの有名なひがさんにもSI業界の現状と未来に関してコメントをしていただきました。(SI業界からはさっさと抜けだしたほうがいい
ただし、SI業界の今後がどうかということや新しいサービスを使ったビジネスのことについては、私自身最先端技術に十分にキャッチアップできておらず、自分の考えを整理できていないため、一旦考えないことにして、ここでは例の試験問題の設計とリファクタリングについて考察してみたいと思います。具体的な例に基づいて説明することで、オブジェクト指向がSI業界の多くの方々に考えられているほど理解不能なものなのではなく、問題を単純化し、プログラムの保守性を桁違いに向上させるうえできわめて重要な役割を果たすということをご理解いただけるものと信じます。実際、ブクマのコメントの中で、

SEもPGも多くは会社員であって専門家ではないから、目先のコストを考えると仕方がない気がする。OOP的な設計、コーディングをした場合、後のメンテナンスを任せることができない悲惨な状況だからこそ。

というコメントをいただいているのですが、正しいオブジェクト指向アーキテクチャーを採用した設計を取り入れることで、従来のスパゲッティコードを保守することに比べて保守性をずっと高くできるのです。いまさらオブジェクト指向?という人も多いと思いますが、私としてはオブジェクト指向技術の魅力を伝えることで、多くの方々の誤解を解くことができればと思います。
もちろん、オブジェクト指向を学ぶには、たしかに、以前のやり方よりもずっと多くの体系的な知識が必要であり、敷居が高いことは否定できません。しかし、ロケットやCPUを設計するような天才エンジニアでなくても、きちんと勉強すれば少なくとも基本的な考え方は多くの人々が学ぶことができるものであると信じます。
ただし、この点についてはあまりはっきりとは言われないようですが、私の考えでは、オブジェクト指向を真に理解し、使いこなすことができるのはオブジェクト指向言語に習熟したプログラマーだけではないのかということがあります。確かに、UMLなどの記法を使った上流の分析や設計の技法もありますが、少なくとも1つのオブジェクト指向プログラミングに習熟したプログラマーとしての経験がないと、オブジェクト指向のエッセンスを本当に理解することはきわめて難しい、あるいは、ほとんど不可能なことのように思うのです。もちろんオブジェクト指向言語でプログラミングができればオブジェクト指向が理解できるということではないですが、少なくともオブジェクト指向プログラミング(OOP)の習熟はオブジェクト指向の理解に欠かすことのできない必要条件だと思います。オブジェクト指向の話がよく理解できないという方は、ほぼ例外なく、プログラミングをしない上流専門の方なのではないかと思います。たとえ話として何冊オブジェクト指向の解説書を読んでも、プログラミングの経験なしではポリモーフィズム、抽象化、カプセル化といったことのニュアンスを体得することはほぼ不可能に近いと思います。これはちょうど、一般相対性理論量子力学の問題を理解するのに数式を使わない一般向けの啓蒙書を何冊読んだところで結局正しい理解に到達できないのと同じことです。であるからこそ、現代的なオブジェクト指向開発ではよく言われるように設計者はプログラマーであるということが望ましいということになるのです。COBOL時代の発想でプログラムを作るPGは下級職であり、設計は上流のSEが行うという古くさい枠組みから離れられないのであれば、正しい設計などできるわけがないのではないでしょうか?
ですから、

  • 設計にかかわる上級職の方も真剣にオブジェクト指向プログラミングの勉強をする
  • プログラミングをする人を下級職とみなさず正しく評価し、設計者として意見やアイデアを尊重する

のいずれかを心がけていただければ、きっとうまくいくはずであると思います。

COBOL時代の設計と違い、現代的なオブジェクト指向設計では基本アーキテクチャーの設計が大切です

この試験問題のオリジナルの設計書は20年以上前のCOBOL時代の設計技法に基づいて記述されています。この時代にはJavaのようなオブジェクト指向言語はまだ一般的ではありませんでした。この時代の設計は構造化設計ですので、

  • 処理の流れをフローチャートとして設計する
  • 機能単位でプログラムをサブプログラムに分割する
  • データ構造(ファイルフォーマット)を設計する
  • 画面レイアウト、書式を設計する

ということがほとんどでした。実際、試験問題の設計書には、メソッドやパラメーターの一覧など、ソースやJava Docを読めばわかるような冗長な内容を除くと上のいずれかの内容以上のものは含まれていませんでした。このような設計であっても

  • 一度作成・試験が完了したら決して機能変更や追加がない
  • ファイルのソート、マージ、書き換えなど定型処理しかしない
  • 非常に長い時間とお金をかけて開発する

など昔ながらのプログラムであれば、何とかなったかもしれません。しかしながら、現代のコンピューターは当時とは速度や記憶容量は文字通り桁違いに進歩していますし、顧客からも以前よりはるかに複雑な要件を短期間に開発することを要求されるようになってきています。したがって、プログラミング言語もより効率的に開発や保守ができるものが登場してきているわけですし、設計やプログラミングのやり方も当然昔とは違った方法で行う必要があります。オブジェクト指向言語が現在主流になっているのは、そうした背景を考えれば必然のことであり、無視し続けることはできないのです。
それゆえ、Javaを使った開発では上記の設計に加えてオブジェクト指向的なアーキテクチャーをきちんと設計することが非常に大切になります。アーキテクチャーの設計ではまず以下のことを考える必要があります。

  • 抽象度(機能固有⇔共通機能)、データとの距離(画面より⇔DBより)などの基準によりレイヤーに分割する。
  • レイヤー間の依存関係や、それぞれのレイヤーの中で実装すべきサービスの内容を決める。
  • 全体的な機能をモジュールごとに分割する。

今回の例題だと、以下のUMLのようなモデルが考えられます。

アプリケーション層ではメニューの選択に対応する個々のユースケース固有の処理を記述するコントローラーロジックが主に格納されます。一方、ドメイン層には上位のレイヤーから参照されるエンティティクラスなどが格納され、インフラ層にはファイルアクセスや画面操作など汎用的なロジックを抽象化して扱いやすくするクラスライブラリーが格納されます。
なお、Java言語ではこうしたモジュールの構造はパッケージという言語構造で表現することができます。もともとの試験のオリジナルのソースでは全クラスがデフォルトパッケージに格納されていました。しかし、このような基本となるアーキテクチャーを決めた場合は、モジュールに対応してソースコード上のパッケージも作成します。実際に以下のようなイメージとなります。

このように、IDEなどの現代的な開発環境では、パッケージの構造もきれいに階層化して表示されますし、クラスの格納場所の移動も自動リファクタリング機能を使ってスイスイ実行できます。昔のコーディングのイメージだとプログラムは文字の羅列というイメージが強いと思いますが、実際はそうではなくて、かなりビジュアルな右脳の感覚を要求される作業であることがお分かりいただけると思います。また、パッケージのどこにどのようなクラスを配置するかということは、UML上よりソースコード上の方がわかりやすく変更も容易ということもあります。ちょうど数学者が数式を展開したりして方程式を解いたり、CADの図面を使って自動車の部品を設計したりするのと同じような感覚で、ソースコード上で設計を自由自在に変えていくということができるのです。最初にExcel方眼紙を使って作図してからソースを起こすということがいかに非効率で無意味なことか、お分かりいただけるのではないでしょうか。(実際この記事で紹介しているUML図はソースをリファクタリングしてからリバースして起こしたものです。)

共通的に使えそうなロジックを共通クラスとして抽象化し再利用する

オブジェクト指向の設計においては、クラスという抽象を適切に考え出すことが設計の中心となります。クラスは入門書だとわかりやすさの配慮から「乗り物」「自動車」など把握しやすい実世界のエンティティをクラスの例として扱うことが多いですが、実際のオブジェクト指向プログラミングにおいては、こうした実世界のもの以外にも機能やデータの塊として便利な単位を抽象化して抽出することが多くあります。実際、この例題の場合、単なるデータのCRUD処理なので、派遣ビジネスという業務ドメインに特化した複雑性というのはまったく存在しません。だから、この部分については、本当の意味でのクラスを抽出する必要はありません。単にファイルの一行分のデータを保持する構造体としてクラスを抽出しておけば十分です。(ただ、試験でなく実際の業務ではこういったコアドメインの領域は仕様変更や機能追加の可能性が高い部分なので将来的にはクラスを抽出するようにリファクタリングする意味が高いのですが。)
この問題において、むしろ、最もコーディングが面倒なのは

  • タブ区切りのテキストファイルを読み書きする処理
  • 画面コンソールにメニューを表示したり、入力を対話的に受け取ったりする処理

などの部分になります。これらの処理には単にロジックだけでなくて、処理を遂行するのに必要な変数も必要になります。したがって、こういう部分こそが、Java言語で言うところの本当のクラスとして抽出する価値がある部分になります。なお、設計の初期の段階ではクラスの実装の中身ではなく、まずは外部からアクセス可能な(publicな)インターフェースに着目して設計することが多いです。*1したがって、以上でそれぞれ抽出した概念にインターフェース名を与え、そのインターフェースに対して呼び出し可能な操作を定義します。たとえば、上記の「タブ区切りのテキストファイルを読み書きする処理」にはRepositoryという名前を与え、以下のようなJavaのインターフェースとして定義します。*2

package sample.common.io;

import java.util.List;

import sample.common.entity.EntityBase;

/**
 * エンティティを永続化するためのレポジトリークラスが実装すべきインターフェースです。
 *
 * @param <E> エンティティに対する総称型パラメーター
 */
public interface Repository<E extends EntityBase> {

	/**
	 * IDでエンティティを検索する。(論理削除済みのエンティティは除外する。)
	 * エンティティが見つからない場合はEntityNotFoundExceptionが送出される。
	 * 
	 * @param id ID
	 * @return 検索結果のエンティティ
	 * @throws EntityNotFoundException エンティティが見つからない場合
	 */
	E findById(long id);

	/**
	 * エンティティを全件検索する。(論理削除済みのエンティティは除外する。)
	 * エンティティが見つからない場合は空のListが返る。
	 * 
	 * @return エンティティのリスト
	 * @throws EntityNotFoundException エンティティが見つからない場合
	 */
	List<E> findAll();

	/**
	 * エンティティの属性値を照合して検索する
	 * @param example 検索対象の属性値を格納したオブジェクト
	 * @return 検索結果に一致したエンティティのリスト
	 */
	List<E> findByExample(E example);

	/**
	 * エンティティを新規作成する。
	 * @param data 作成対象のエンティティ
	 */
	void create(E data);

	/**
	 * エンティティを更新する。
	 * @param data 更新対象のエンティティ
	 */
	void update(E data);

	/**
	 * エンティティを論理削除する。
	 * @param data 論理削除対象のエンティティ
	 */
	void delete(long id);
}

このインターフェースは総称型のパラメーター付きで定義されているため、任意のエンティティクラスに対して汎用的に利用することができます。したがって、一度正しく実装してしまうと、HumanResouce(人材)だろうが、Work(稼動)だろうが、どのクラスに対しても再利用できます。しかも、ファイルを読み書きするロジックはこのインターフェースの中身にカプセル化されていますので、利用側はまったく意識する必要がありません。
たとえば、人材をIDで検索する処理は以下のように非常に簡単に記述できるようになります。

    HumanResource hr = hrRepository.findById(id);

同様に、稼動についても

    Work work = workRepository.findById(id);

とほとんど同じ一行で済んでしまいます。英語なので日本人にはちょっと読みにくいのですが、英語脳で読めば、「HRのレポジトリーからIDで検索したHR情報を保持する。」ということであり、上流の仕様書の記述レベルと変わるところがありません。(多くの日本人がオブジェクト指向プログラミングを苦手とするのは英語アレルギーだからか?)これを以下に引用する、もともとのオリジナルのソースと比べてみれば、どちらが単純であるか一目瞭然です。(この場合、個々のエンティティごとに似たようなロジックを作り直す必要もあります。)

(Javaプログラミング能力認定試験1級問題より引用)

	/** 指定された人材IDのすべての情報の読込み
	 * @return 人材情報
	 */
	String[][] getjData() {
		BufferedReader br = null;
		try {
			br = new BufferedReader( new FileReader( "jinzai.txt" ) );
						//人材情報マスタを開く
			String instr;

			//人材情報マスタから1レコードずつ読込み
			while( (instr = br.readLine()) != null ) {
				if( instr.substring( 0, instr.indexOf('\t') ).equals(jID) ) {
								//人材IDが一致
					if( (instr.length()-1) == instr.lastIndexOf('\t') ) {
								//削除日付なし
						return setjData( instr );	//人材情報を返す
					}
				}
			}
			
		} catch( FileNotFoundException e ) {
					//人材マスタがない
		} catch( IOException e ) {	
					//人材情報マスタへのアクセスエラー
		} finally {
			try {
				if ( br != null)
					br.close();	//人材情報マスタを閉じる
			} catch( IOException e) {
				//正常にクローズできなかった場合のエラー
			}
				
		}
		return null;
	}

カプセル化によるメリットは単にソースコードの行数が共通化により少なくなるということだけではありません。カプセル化によって、今後ファイルの項目が変更されたり、フォーマットが変更されても、あちこちのコードを修正する必要がなくなるため、保守性も実際にぐんと向上するのです。
ちなみに、Repositoryはインターフェースですので、別途実装クラスが必要になります。クラス図で関係を示すと以下のようになります。

なお、参考までに、Repositoryインタフェースの実装例を以下の記述します。このクラスを一つ実装、単体テストしておけば、あらゆるエンティティに対するCRUD処理で汎用的に再利用できるようになります。

package sample.common.io;

import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.File;
import java.io.FileReader;
import java.io.FileWriter;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;

import org.apache.commons.lang.StringUtils;

import sample.common.SystemException;
import sample.common.entity.EntityBase;
import sample.common.util.ExampleMatcher;
import sample.common.util.IdMatcher;
import sample.common.util.Matcher;
import sample.common.util.TrueMatcher;

/**
 * 区切り文字を使ったテキストファイル上のデータを読み書きするためのレポジトリー実装クラスです。
 * このクラスはステートを持ちスレッドセーフではない点に注意すること。
 */
public class CharSeparatedFileRepository<E extends EntityBase> implements
		Repository<E> {

	// ====================================================
	// フィールド
	// ====================================================

	private File masterFile;

	private File workFile;

	private String separator = "\t";

	private BufferedReader reader;

	private BufferedWriter writer;

	private Class<E> entityClass;

	private final TrueMatcher<E> TRUE_MATCHER = TrueMatcher.instance();

	// ====================================================
	// プロパティ
	// ====================================================

	public File getMasterFile() {
		return masterFile;
	}

	public void setMasterFile(File masterFile) {
		this.masterFile = masterFile;
	}

	public File getWorkFile() {
		return workFile;
	}

	public void setWorkFile(File workFile) {
		this.workFile = workFile;
	}

	public Class<E> getEntityClass() {
		return entityClass;
	}

	public void setEntityClass(Class<E> entityClass) {
		this.entityClass = entityClass;
	}

	public String getSeparator() {
		return separator;
	}

	public void setSeparator(String separator) {
		this.separator = separator;
	}

	// ====================================================
	// メソッド
	// ====================================================

	private List<E> doFind(Matcher<E> matcher) {
		try {
			List<E> result = new ArrayList<E>();

			openForRead();
			String line;

			// マスタから1行ずつ読込み
			while ((line = reader.readLine()) != null) {
				E entity = toEntity(line);
				if (!entity.isLogicalDeleted() && matcher.isMatch(entity)) {

					result.add(entity);
				}
			}

			return result;

		} catch (IOException e) {
			throw new SystemException("検索処理実行時にIO例外が発生しました。", e);
		} finally {
			close();
		}
	}

	@Override
	public E findById(long id) {

		Matcher<E> idMatcher = new IdMatcher<E>(id);

		List<E> result = doFind(idMatcher);

		if (result.isEmpty()) {
			throw new EntityNotFoundException("id = " + id + "のエンティティは存在しません。");
		}

		// TODO 一意性チェックはしていない
		return result.get(0);
	}

	@Override
	public List<E> findAll() {
		return doFind(TRUE_MATCHER);
	}

	@Override
	public List<E> findByExample(E example) {
		Matcher<E> exampleMatcher = new ExampleMatcher<E>(example);

		return doFind(exampleMatcher);
	}

	static interface FileUpdator {
		void handle() throws IOException;
	}

	private void processUpdate(FileUpdator fileUpdator) {
		try {
			openForWrite();

			fileUpdator.handle();

		} catch (IOException e) {
			throw new SystemException("削除処理実行時にIO例外が発生しました。", e);
		} finally {
			close();
		}

		commit();
	}

	private void writeEntity(E data) throws IOException {
		String outputLine = fromEntity(data);
		writer.write(outputLine);
		writer.newLine();
	}

	@Override
	public void create(final E data) {
		if (data == null) throw new IllegalArgumentException("パラメーターが不正です。");

		processUpdate(new FileUpdator() {
			
			@Override
			public void handle() throws IOException {
				String line;

				List<Long> idList = new ArrayList<Long>();
				// マスタから1行ずつ読込み
				while ((line = reader.readLine()) != null) {
					E entity = toEntity(line);
					idList.add(entity.getId());

					writeEntity(entity);
				}

				long maxId = Collections.max(idList);
				data.setId(maxId + 1);

				data.preCreate(); // 更新、作成日付の発行
				writeEntity(data);
			}
		});
	}

	@Override
	public void update(final E data) {
		if (data == null)
			throw new IllegalArgumentException("パラメーターが不正です。");
		if (!data.isPersisted())
			throw new IllegalArgumentException("パラメーターが永続化されていません。");

		processUpdate(new FileUpdator() {
			@Override
			public void handle() throws IOException {
				String line;

				// マスタから1行ずつ読込み
				while ((line = reader.readLine()) != null) {
					E entity = toEntity(line);
					if (data.getId().equals(entity.getId())) {
						if (entity.isLogicalDeleted()) { // 既に論理削除済みの場合
							throw new EntityNotFoundException("id = "
									+ entity.getId() + "のエンティティは既に論理削除されています。");
						}

						data.preUpdate();
						entity = data;
					}

					writeEntity(entity);
				}
			}
		});
	}

	@Override
	public void delete(final long id) {
		processUpdate(new FileUpdator() {
			@Override
			public void handle() throws IOException {
				String line;
				boolean deleted = false;

				// マスタから1行ずつ読込み
				while ((line = reader.readLine()) != null) {
					E entity = toEntity(line);

					if (id == entity.getId()) {
						if (entity.isLogicalDeleted()) { // 既に論理削除済みの場合
							throw new EntityNotFoundException("id = " + id
									+ "のエンティティは既に論理削除されています。");
						}

						entity.logicalDelete();
						deleted = true;
					}

					writeEntity(entity);
				}

				if (!deleted) {
					// パラメーターで指定されたエンティティが存在しなかった場合
					throw new EntityNotFoundException("id = " + id
							+ "のエンティティは存在しません。");
				}
			}
		});
	}

	private String fromEntity(E entity) {
		return StringUtils.join(entity.toArray(), getSeparator());
	}

	private E toEntity(String line) {

		try {
			E entity = entityClass.newInstance();
			entity.fromArray(StringUtils.split(line, getSeparator()));

			return entity;
		} catch (InstantiationException e) {
			throw new SystemException("エンティティの復元時に例外が発生しました。", e);
		} catch (IllegalAccessException e) {
			throw new SystemException("エンティティの復元時に例外が発生しました。", e);
		}
	}

	private void commit() {
		try {
			if (!masterFile.delete()) {
				throw new IOException();
			}

			// テンポラリーファイルをマスタに置換え
			workFile.renameTo(masterFile);

		} catch (IOException e) {
			throw new SystemException("ワークファイルの変更をマスターファイルに反映できません。", e);
		}
	}

	// NOTE
	// 本来は全ファイルの内容をメモリ上に読み込んで処理したほうが簡単だが、
	// オリジナルの実装を極力残すことにした。

	private void openForWrite() throws IOException {
		reader = new BufferedReader(new FileReader(masterFile));
		writer = new BufferedWriter(new FileWriter(workFile));
	}

	private void openForRead() throws IOException {
		reader = new BufferedReader(new FileReader(masterFile));
	}

	private void close() {
		if (reader != null) {
			try {
				reader.close();
			} catch (IOException e) {
				e.printStackTrace();
			}
		}

		if (writer != null) {
			try {
				writer.close();
			} catch (IOException e) {
				e.printStackTrace();
			}
		}
	}

}

(その2につづく)

*1:これはテストファースト(TDD)のような手法でも本質的には変わりません。先にメソッドのインターフェースを決め、テストクラスを作ってからクラスの中身の実装を完成させます。

*2:適切なクラスの抽出は確かに長年の経験からくる匠の勘を要求されるところであり、初心者プログラマーには荷が重いところです。しかし、デザインパターンなどの本を勉強することで、ある程度クラス抽出のヒントを得ることが可能です。