よりGroovyらしいプログラムへのリファクタリングに挑戦してみました
先日認定試験問題のリファクタリング結果をGroovyにとりあえず移植してみました - 達人プログラマーを目指してにてJavaプログラミング認定試験のサンプルプログラムのGroovy化について紹介しました。その記事を書いた時点では、とにかくGroovyとJavaとの親和性という特徴を生かし、あまりGroovyらしさということを考えずにとりあえずGroovyに移植しました。
ここでは、Groovy言語の特徴をいくつか活用することで、さらなる、プログラムの簡易化に挑戦してみたいと思います。
ソースコードは以下(GitHub - ryoasai/certification-refactoring-groovy: Java認定試験のリファクタリングサンプル(Groovy版))
配列のListとの互換性の強化
業務系のJavaプログラマーが知っておくべき10個のBad Partsとその対策 - 達人プログラマーを目指してでBad Partsの一つとして挙げていましたが、Javaでは配列のListとの間に互換性がまったくなく、また、相互の変換も簡単ではありません。例として以下のロジックを見てください。
(HumanResource.javaの抜粋) @Override public String[] toArray() { List<String> dataList = new ArrayList<String>( Arrays.asList( String.valueOf(getId()), getName(), getPostalCode(), getAddress(), getTelephoneNo(), getFaxNo(), getEmail(), getBirthDay(), getGenderType(), String.valueOf(getOccupationId()), getYearOfExperience(), getSchoolBackground(), getRequestedSalary())); dataList.addAll(createDateColumns()); return dataList.toArray(new String[dataList.size()]); }
Groovyでは配列とListとの互換性が強化されているため、以下のように簡単に記述することができます。
@Override String[] toArray() { def dataList = [ id, name, postalCode, address, telephoneNo, faxNo, email, birthDay, genderType, occupationId, yearOfExperience, schoolBackground, requestedSalary] dataList + createDateColumns() }
メソッド中では値の追加が容易に行えるようにListを使用していますが、戻り値は配列として宣言されています。この場合、実行時に自動的に変換が行われます。*1もちろん、可能であれば、配列を使わずにすべてListに統一するということもできますが、インターフェースの互換性の問題などで配列に変換したくなる場合には便利ですね。
Groovyの強力な文字列処理機構を活用する
これもJavaのBad Partsの一つとして挙げていましたが、言語そのものと標準APIの範囲内では文字列処理はJava言語の弱点の一つです。*2このサンプルプログラムのJava版では端末画面にタブ区切りでメニュー項目を表示するために以下のような醜い分岐構造がいたるところに使われていました。
(HumanResourceView.javaの抜粋) public void display(HumanResource hr) { String occupationName = getOccupationName(hr.getOccupationId()); console.display(""); // 改行 String[] hrArray = hr.toArray(); // 人材情報の表示 // TODO かなり醜いコード for (int i = 0; i < FIELDS.length; i++) { StringBuilder sb = new StringBuilder(FIELDS[i] + " : "); if (i == 8) { // 性別の表示 if (hrArray[i].equals("M")) { sb.append("男"); } else if (hrArray[i].equals("F")) { sb.append("女"); } } else if (i == 9) { sb.append(occupationName); // 業種名の表示 } else { sb.append(hrArray[i]); } if (i == 10) { sb.append("年"); // 経験年数の表示 } else if (i == 12) { sb.append("円"); // 希望単価の表示 } if (i == 2 || i == 3 || i == 5 || i == 6 || i == 8 || i == 10) { sb.append("\n"); } else { sb.append("\t "); } console.display(sb.toString()); } }
以上のロジックを見ても一見何が行われるのかを直感的に理解できないのですが、結局のところ以下のような画面を表示している部分になります。
人材ID 25 氏名 test 郵便番号 454 住所 United States 電話番号 080 FAX番号 090 e-mailアドレス booch@ 生年月日 1957 性別 男 業種 null 経験年数 30年 最終学歴 Uni 希望単価 2000円
結局上記の複雑な分岐処理は特定の場所で改行する場所を設定しているということだけです。Groovyの場合、先日も紹介したGStringという文字列テンプレートエンジンの機能が言語に組み込まれており、この仕組みを活用するのであれば、以下のように書式そのものを単純に定義することが可能です。
(HumanResourceView.groovyの抜粋) void display(HumanResource hr) { final def TEMPLATE = """ 人材ID ${hr.id} 氏名 ${hr.name} 郵便番号 ${hr.postalCode} 住所 ${hr.address} 電話番号 ${hr.telephoneNo} FAX番号 ${hr.faxNo} e-mailアドレス ${hr.email} 生年月日 ${hr.birthDay} 性別 ${hr.genderType == 'M' ? '男' : '女'} 業種 ${getOccupationName(hr.occupationId)} 経験年数 ${hr.yearOfExperience}年 最終学歴 ${hr.schoolBackground} 希望単価 ${hr.requestedSalary}円 """.stripIndent() console.display '' // 改行 console.display TEMPLATE }
なお、ここでは、3つの引用符を連続して記述するロング文字列の記法を併せて使っていることに注意してください。さらに、この方法を使うことでHumanResourceのオブジェクトを文字列配列に変換する処理も不要になっています。もちろん、Java言語でも別途テンプレートエンジンのライブラリー*3を使うことで上記のようなテンプレート化には対応できますが、Groovyの場合は言語そのものにテンプレートエンジンが組み込まれているため非常に簡単に利用できるのがよいですね。
なお、表示ロジックの簡易化にはテンプレート化とは別のアプローチも考えられます。結局は画面の適当な場所で改行コードが入ればよいのですから、自動的に文字列を折り返すメソッドを定義しておけば、わざわざ固定のテンプレートを個別に定義する必要がなくなります。
たとえば、もともとの以下のコードを見てください。
(UpdateHRFunction.javaの抜粋) private void displayMenuItems(StringBuilder buff) { for (int i = 1; i < HumanResourceView.FIELDS.length; i++) { buff.append(i + "." + HumanResourceView.FIELDS[i]); // TODO かなり醜いロジックだが、現状のロジックを保存しておく。 // 本来はタブ位置を汎用的に自動調整するロジックを書くべき if (i == 1 || i == 8 || i == 9) buff.append("\t"); if (i == 3 || i == 5 || i == 7 || i == 10 || i == 12) buff.append("\n"); else if (i != 6) buff.append("\t"); } buff.append("\n [1-12]>"); }
Groovy版では以下のように修正してみました。まず、メタ情報としてエンティティの各プロパティの名称のマップを以下のように定義しておきます。マップのキーはフィールド名、値は画面表示文字列です。*4
public static final def FIELDS_MAP = [ name: '氏名', postalCode: '郵便番号', address: '住所', telephoneNo: '電話番号', faxNo: 'FAX番号', email: 'e-mailアドレス', birthDay: '生年月日', genderType: '性別', occupationId: '業種', yearOfExperience: '経験年数', schoolBackground: '最終学歴', requestedSalary: '希望単価']
このようにしておいて、以下のように行を折り返す汎用的なメソッドを定義することができます。
(ConsoleImpl.groovyの抜粋) int acceptFromMenuItems(Map<String, String> menuMap, int maxWidth = 80) { def buf = new StringBuilder() menuMap.eachWithIndex {key, value, i -> def item = "${i + 1}.${value}" buf << item << ' ' } display wrap(buf.toString(), maxWidth) acceptInt("[1-${menuMap.size()}]>") { if (it < 1 || it > menuMap.size()) { // 項目番号入力エラー display '項目番号の入力が正しくありません。' return false } true } } private def wrap(text, maxWidth = 80) { def line = new StringBuilder() def allLines = [] text.eachMatch(/\S+/) { item -> if (line.size() + 8 + item.size() > maxWidth) { allLines << line.toString() line = new StringBuilder() } line << (line.length() == 0 ? item : '\t' + item) } allLines << line.toString() allLines.join('\n') }
こうしておけば、呼び出し側は以下のように簡単に記述することができるようになります。
private int inputItemNo() { console.display '更新したい項目を入力してください。' console.acceptFromMenuItems(HumanResource.FIELDS_MAP, 30) }
ただし、この方法をとる場合、一般的には、画面仕様を改変してもらう必要が出てきます。残念ながらSI業界においては現在においてもなお、COBOL時代の慣習を引き継ぎ上流工程で画面仕様が決まってしまっており、後から調整が難しいということがあるようです。しかし、データの入力という機能が実現できることが本来は本質的なのであって、改行位置を仕様書どおりに作成することはエンドユーザーにとって多くの場合どうでもよいことだったりします。残念ながら、伝統的なウォーターフォールモデルを採用した開発では、こうした本質的でない部分への対処にプログラマーが実に多くの工数とエネルギーを割かざるを得ないという現状が少なからず存在するということをこの場で指摘させていただきたいと思います。
GroovyではオブジェクトとMapをお互いに同等に扱うことができる
JavaScriptなどの動的な言語のプログラマーであれば理解しやすいのですが、GroovyではオブジェクトとMapに対してそれぞれ同じような記述をすることが可能になっています。たとえば、
class Persion { String name int age }
のようなBeanがあった場合、以下のどちらの記述方法でも値の設定、取得が可能です。
def persion = new Person() // 通常の記法 println person.name person.age = 30 // Map形式の記法 println person['name'] person['age'] = 30
今度はまったく逆にMapのインスタンスに対しては通常のMapの要素アクセスの形式でなく、オブジェクトのプロパティアクセスの形式が使えます。
def personMap = [name: "test", age: 30] // 通常の記法 println personMap.name personMap.age = 30 // Map形式の記法 println personMap['name'] personMap['age'] = 30
結局裏ではリフレクションが使われているのですが、Groovy言語のプログラマーはまったく詳細を意識することなく簡単にオブジェクトのプロパティにアクセスすることができるのです。この機能を活用すると、たとえば、以下のように値を入力する機能の実現は非常に簡単にできます。
/** * 人材情報入力処理 */ @Component class InputHRFunction implements Function { @Inject HumanResourceRepository hrRepository @Inject OccupationRepository occupationRespository @Inject Console console /** * 人材管理(追加)の実行 */ void run() { def hr = new HumanResource() inputData(hr) hrRepository.create(hr) console.display "人材ID: ${hr.id}で登録されました。" } /** * 人材情報の入力 */ private def inputData(hr) { HumanResource.FIELDS_MAP.each { key, value -> def message = "${value}を入力してください。" if (value == '性別') { hr[key] = console.accept(message) {input -> 'M' == input || 'F' == input } } else if (value == '業種') { hr[key] = console.acceptFromIdList(occupationRespository.findAll(), message) } else { hr[key] = console.accept(message) } } } }
もともとのJava版ではいったんString[ ]に入力値を格納してから、オブジェクトに変換していたのですが、Groovyではダイレクトにプロパティに値を設定することが簡単に行えます。
Groovyではリフレクションを非常に簡単に扱える
Mapのようにプロパティにアクセスできるという前節の内容も関係しているのですが、Groovyでは非常に簡単にリフレクション機能を活用することができます。Javaはバージョン1.1の時代から高度なリフレクションのAPI(とその上位レベルAPIとしてのJavaBeansプロパティのイントロスペクション機能)を備えており、この機能を活用することで静的言語でありながらも非常に柔軟性の高いメタプログラミングが以前から可能でした。リフレクションAPIはJavaで有用なフレームワークが開発されるきっかけとなったものであり、JavaのGood Partsに加えるべきものだと私は思います。ただし、残念ながら不適切にチェック例外を使用しているために、必要以上に使い勝手の悪いAPIとなってしまっています。Groovyを使えば、Javaの欠点を補いながらも高度なリフレクション機能をフルに活用することができます。この点、フレームワークを開発するプログラマーの立場から、私はリフレクションがGroovyの最大の魅力の一つと考えています。
なお、Javaでリフレクションを使う場合も、直接JDKのAPIを使わず、Springなどが提供するラッパーを使うことである程度簡易化することはできます。ただし、Groovyの方がさらに簡単です。以下は、二つのオブジェクトのプロパティを相互に比較し、null以外のフィールドの値が一致しているかどうか(文字列の場合は部分一致)を判定するロジックです。
package sample.common.util; import java.beans.PropertyDescriptor; import java.lang.reflect.Method; import org.apache.commons.lang.ObjectUtils; import org.springframework.beans.BeanUtils; import org.springframework.util.ReflectionUtils; public class ExampleMatcher<T> implements Matcher<T> { private T example; public ExampleMatcher(T example) { if (example == null) throw new IllegalArgumentException(); this.example = example; } public boolean isMatch(T target) { if (target == null) return false; PropertyDescriptor[] props = BeanUtils.getPropertyDescriptors(example.getClass()); for (PropertyDescriptor prop : props) { Method readMethod = prop.getReadMethod(); if (readMethod == null) continue; if (prop.getName().equals("class") || prop.getName().equals("persisted")) continue; Object exampleValue = ReflectionUtils.invokeMethod(readMethod, example); if (exampleValue == null) continue; if (exampleValue instanceof Long && (Long)(exampleValue) == 0) continue; // 基本型のlongの0は無視(いまいち) Object targetValue = ReflectionUtils.invokeMethod(readMethod, target); if (targetValue instanceof String && exampleValue instanceof String) { // 部分文字列一致 if ( ! ((String)targetValue).contains((String)exampleValue)) return false; } else { if (!ObjectUtils.equals(exampleValue, targetValue)) return false; } } return true; } }
Groovyだとクロージャーを使って以下のように実装できます。任意のオブジェクトに対してpropertiesというプロパティにアクセスするとプロパティー名にキー、プロパティ値を値とするMapを簡単に取得できるのがポイントです。
def hasUnequalValue = example.properties.any { key, value -> // 以下のプロパティ値は比較対象外とする if (key == 'class' || key == 'metaClass' || key == 'persisted') return false if (value == null) return false if (value == 0L) return false // 基本型のlongの0は無視(いまいち) def targetValue = it[key] if (targetValue instanceof String && value instanceof String) { !targetValue.contains(value) // 文字列は部分一致 } else { value != targetValue // その他の値は完全一致 } }