MDC Advent Calendar 2020 の14日目です。
MDCって名前だからslf4jの記事が25本集まるニッチなカレンダーかと思ったら違った。
環境も文化も急速に変化した一年だったので、だけど変わらないものもいいよね、ということで
自戒と反省を込めつつ、イミュータブルプログラミングの話を書きます。
Javaです。
前提
以下のようなどこにでもある普通のアジャイル開発案件を前提に考えます。
- 通信しなければならない外部エンドポイントが30個くらいある
- 通信プロトコルはSOAP
- そのうちいくつかはウォーターフォールで並行開発している
- Sprint毎に通信機能を増やしていくが、各エンドポイントは開発環境のものであっても気軽に接続できない
- ウォーターフォールとの並行開発なので結合テストフェーズがあり、基本的にはそこまで接続おあずけ
- プロジェクト後半期までは自分たちでモックエンドポイントを作って通信の疎通確認
- 各エンドポイントは、リクエスト・レスポンス共に項目が50〜100個くらい
- エンドポイント仕様書は縦にとても長い、すばらしく精巧なExcel
- 仕様書とはいえ、どの項目値を送信すればどんな結果になるかは明瞭に記載されていないため、
各エンドポイントのオーナーに頭を下げてヒアリングする必要がある
- 仕様書とはいえ、どの項目値を送信すればどんな結果になるかは明瞭に記載されていないため、
- 結合テストでの初リクエスト時に必須項目漏れや桁数誤りで異常応答となると、色々とめんどくさい
どこにでもある普通のやつです。
めんどくさい事態を避けたい
いくら気を付けてもヒューマンエラーは無くなりませんし、プレッシャー感じながら常に血眼で実装するのも心理的安全でないので
レビューやテストの検知漏れを問題視し、そちらのプロセス改善を図るのが不具合撲滅の早道かと思います。
ただどうあがいても見落としは発生するものなので、根本的な仕組みレベルでミスを減らすような方法を考えます。
そこでイミュータブルプログラミングです。
なぜ埋め込まれたか
やっと本題です。プログラムの構成は下図のような形となります。
以下に示すサンプルコードの中に不備をいくつか埋め込んであるので探してみてください。
SampleService(業務判断用クラス)
ロジックを書くとこです。
自システムで持ち回している値を、通信先システムのエンドポイントが適合する形に編集して
通信用クラス(SampleSoapClient
)を呼び出す役割です。
/**
* 業務判断を元にリクエスト情報を生成するクラス.
*/
@RequiredArgsConstructor
@Service
public class SampleServiceImpl implements SampleService {
@NonNull
private SampleSoapClient client;
public void callService(String valueA, String valueB, int valueC) {
SampleSoapClientDto dto = new SampleSoapClientDto();
// 業務ロジック.
// 通信先が求める形に適合するように送信値を生成する.
dto.setFieldA(valueA);
dto.setFieldB(valueA + valueB);
dto.setFieldC(String.valueOf(valueC));
dto.setFieldD("000" + (valueC * 100));
if (valueA.equals(valueB)) {
dto.setFieldF("A");
} else {
dto.setFieldF("B");
}
client.execute(dto);
}
}
SampleSoapClientDto(データ移送用クラス)
前述のServiceから後述のClientに値を移送するためのクラスです。
例としてString
でfieldA
〜fieldF
を定義していますが、
実際はもっと多かったり複雑だったりします。
この全ての値は通信先にとって必須の送信項目であるものとします。
/**
* SeviceからSoapClientに受け渡すデータ転送用クラス.
*/
@Getter
@Setter
public class SampleSoapClientDto {
private String fieldA;
private String fieldB;
private String fieldC;
private String fieldD;
private String fieldE;
private String fieldF;
}
SampleSoapClient(通信用クラス)
リクエストをぶん投げます。
SOAP通信先から提供された通信用クラス(例ではSoapServiceElement
)に対して、
Serviceから受け取った値を横流しして呼び出し、通信処理を行う役割です。
なお簡略化して書いているだけですので、通信先によっては階層や構造が例よりも遥かに複雑なこともきっとあるでしょう。
/**
* SampleというSOAPエンドポイントに対する通信を行うクラス.
*/
@Component
public class SampleSoapClient {
public void execute(SampleSoapClientDto dto) {
// 通信先から提供されている通信用クラスに値を詰め替える.
SoapServiceElement element = new SoapServiceElement();
element.setFieldA(dto.getFieldA());
element.setFieldB(dto.getFieldB());
element.setFieldC(dto.getFieldC());
element.setFieldD(dto.getFieldD());
element.setFieldE(dto.getFieldE());
// 提供された通信用クラスを用いての通信処理.
request(element);
}
}
答え合わせ
上記コードには不備が2箇所あります。端的に言うとsetter呼び忘れです。
-
SampleServiceImpl
で、SampleSoapClientDto
のfieldE
をsetしていない -
SampleSoapClient
で、SoapServiceElement
のfieldF
をsetしていない
結果、必須項目に値が入らず、リクエストするとあえなく異常応答となります。
今回のレベルであればレビューや自動テストで検出できるレベルかと思いますが、
項目や構造が複雑になるにつれて、うっかり埋め込んで発見できないリスクは上昇していきます。
不完全な状態のインスタンスを作らない
ではどうすべきか考える前に、まずは以下の例で考えてみます。
/**
* 従業員クラス.
*/
@Setter
@ToString
public class Employee {
private int age;
private int salary;
private String name;
}
public class EmployeeUtil {
/**
* 従業員インスタンスを作成する.
*/
public static Employee createEmployee(int age, int salary, String name) {
Employee employee = new Employee(); // ❶
employee.setAge(age); // ❷
employee.setSalary(salary); // ❸
employee.setName(name); // ❹
return employee;
}
}
Employee
は従業員を表すクラスです。
従業員である以上、年齢(age)も給与(salary)も氏名(name)も必ず存在するはずですが
❶〜❹各行の実行直後のemployee
を出力すると以下のようになります。
Employee(age=0, salary=0, name=null)
Employee(age=30, salary=0, name=null)
Employee(age=30, salary=300000, name=null)
Employee(age=30, salary=300000, name=foo太郎)
❹で全てのフィールドをsetし終わる前の❶〜❸は、
常識的には必須のはずの年齢や氏名が存在しない、実態としてありえない状態となっています。
先の例でも、SampleSoapClientDto
の必須項目fieldE
に対するsetが漏れており
結果として不完全なリクエストを送信することとなりました。
根本的な原因は、空のインスタンスを作って後から中身をsetするというJavaBeansパターンを用いていることです。
❶〜❸のようなありえない状態がコード上に存在し得るというのはシステムとしてリスクです。
コンストラクタが一つも定義されていない場合、引数なしコンストラクタがデフォルトコンストラクタとして自動生成されるため、
デフォルトコンストラクタを利用する場合、中身がないインスタンスにsetterで値を詰めることになりますが
そもそも引数ありコンストラクタを定義すればこの状態を封じることができます。
/**
* 従業員クラス.
*/
@ToString
@AllArgsConstrucor // 全てのフィールドを引数に取るコンストラクタを生成する黒魔術
public class Employee {
private int age;
private int salary;
private String name;
}
public class EmployeeUtil {
/**
* 従業員インスタンスを作成する.
*/
public static Employee createEmployee(int age, int salary, String name) {
Employee employee1 = new Employee(age, salary, name); // 生成できる
Employee employee2 = new Employee(); // コンパイルエラー
}
}
引数なしコンストラクタが利用できなくなったので
とりあえず、不完全な状態のインスタンスは生成されなくなりました。
ただ、この変更のキモは引数なしコンストラクタを排除したことではなく、
全てのフィールドに代入を行うコンストラクタを定義したことで、setterを削除できたことです。
コンストラクタを新たに定義しても、setterが残ったままであれば
宣言時以外の箇所で値を好き放題変えられるリスクは残ったままなので大差ありません。
setterを消せれば、そのクラスのフィールドの不変性はある程度保たれます。
極端な例ではあるものの、以下のような副作用のあるメソッドで密かに事故ることも防げます。
setterが提供されないので書き換えは行えません。
/**
* ボーナスの金額を計算する.
*/
public static int calcBonus(Employee employee) {
int bonus = employee.getSalary() * 1.5;
employee.setSalary(0); // なぜかうっかりフィールドを書き換えている(人間は愚かなので)
return bonus;
}
サブクラスでの代入を禁止する
Employeeクラスからsetterを排除して安心していましたが、
ある日別のブランチを見ると、管理者を示すManagerクラスが新たに定義されていました。
Employeeクラスを継承しているようです。
アノテーションを見てみると・・・
/**
* 管理者クラス.
*/
@Setter
public class Manager extends Employee {
private int rank;
public Manager(int age, int salary, String name) {
super(age, salary, name);
}
}
public class EmployeeUtil {
/**
* 管理者インスタンスを作成する.
*/
public static Manager createManager(int age, int salary, String name) {
Manager manager = new Manager(40, 400000, "bar次郎");
manager.setAge(50);
return manager;
}
}
setter生えとる〜再代入しとる〜〜
Employeeインスタンスのフィールドは不変なままですが、
これではManagerインスタンスのフィールドに年齢や氏名を好き放題代入できてしまいます。
ということで更に制約を課します。
/**
* 従業員クラス.
*/
@AllArgsConstructor
public class Employee {
private final int age; // final修飾子を付与
private final int salary; // final修飾子を付与
private final String name; // final修飾子を付与
}
/**
* 管理者クラス.
*/
@Setter
public class Manager extends Employee {
private int rank;
public Manager(int age, int salary, String name) {
super(age, salary, name);
}
}
public class EmployeeUtil {
/**
* 管理者インスタンスを作成する.
*/
public static Manager createManager(int age, int salary, String name) {
Manager manager = new Manager(40, 400000, "bar次郎");
manager.setAge(50); // コンパイルエラー : Cannot resolve method 'setAge(int)'
return manager;
}
}
Employeeクラスの必須フィールドにfinal修飾子を付与しました。
こうすることで、何があっても値の再代入は禁止されますし
サブクラスで@Setter
を付与したとしても、finalフィールドへのsetterは自動生成されません。
また、final修飾子をつけたフィールドは生成時に初期化を強制されるので
仮に新たなコンストラクタを定義したとしても、finalフィールドを初期化するように実装しないとエラーとなります。
/**
* 従業員クラス.
*/
public class Employee {
private final int age;
private final int salary;
private final String name;
// コンパイルエラー : Variable 'name' might not have been initialized
// nameを初期化しないコンストラクタが定義されているため
public Employee(int age, int salary) {
this.age = age;
this.salary = salary;
}
}
宣言時も厳しく見張る
人は愚かという前提に立つなら、以下のケースを想定しておくことも必要かもしれません。
final修飾子はあくまで再代入を防げるだけなので、nullで初期化されると困ります。
public class EmployeeUtil {
public static Employee createAnonymousEmployee(int age, int salary) {
// 平然とnull入れてる
Employee employee1 = new Employee(age, salary, null);
}
}
@NonNull
とか付けておくと後続処理の中ではなく宣言時に気付けるのでよいかもしれません。
(nullを代入した瞬間にNullPointerException
が発生します)
ただプリミティブ型では当然ながらスルーします。
/**
* 従業員クラス.
*/
@AllArgsConstructor
public class Employee {
private final int age;
private final int salary;
@NonNull
private final String name;
}
ルール強制できていいなと思うので個人的には推しているんですが、
以下二つをアノテーションで簡単に実現できる方法をご存知の方いたらコメントで教えてください。。
- nullが代入された瞬間(実行時)に検知でなく、そもそもコンパイルエラー扱いにしたい
-
@Pattern
みたいに桁数とか数値範囲も代入時にチェックしたい
まとめ
まとめると、以下の工夫をするだけで不完全なインスタンスの生成や設定漏れを防ぐことができ
不変なのでフィールドの値が何なのかトレースすることも容易になります。
- 必須のフィールドを初期化するコンストラクタを定義する
- setterを使わない
- 必須のフィールドをfinalにする
- (Optional)
@NonNull
で不正な初期化を防ぐ
もう一つ、この手法の個人的なメリットを挙げます。
冒頭の例では、SampleService
, SampleSoapClient
, SampleSoapClientDto
の3つを挙げました。
今一度役割を整理すると、以下のようになります。
-
SampleService
: 業務判断用クラス。業務的なロジックを記述する。 -
SampleSoapClientDto
: データ移送用クラス。ServiceからClientに渡すデータを定義する。 -
SampleSoapClient
: 通信用クラス。通信先から提供されたクラスに値を設定しリクエストする。
各クラス実装時の気持ちになってみると、
業務判断クラスを作るときはロジックの実装で頭がいっぱいです。
通信用クラスを作るときは提供されたクラスの構造を読み解き、値を設定するのに手一杯です。
ではデータ移送用クラスを作るときは?
フィールド定義だけなので恐らく一番気楽なはずです。
仕様書を読み取って、必須か否か、桁数はいくつかなど確認するのは案外骨が折れる作業です。
Service
とClient
を作成する前にデータ移送クラスを定義し、
移送しなければいけない情報とその仕様を、修飾子やアノテーションで一つ一つフィールドに落としていくことで
脳内関心事が取っ散らかり、仕様を見落とすことをある程度防げるのではないでしょうか。
ちなみに、ここまで色々と書いてきましたが
上記の例でSampleSoapClient
がインスタンス化する外部提供クラス(SoapServiceElement
)は、通信先で作られたものなので
それがJavaBeans(引数なしコンストラクタ+setter)だった場合、結局苦しみは消えないというオチがつきます。
自分たちの手が届く範囲だけでも導入して少しでもリスク減らせたらいいね、というお話でした。
[APPENDIX] 引数なしコンストラクタが絶滅した後の世界
何でも引数なしコンストラクタとsetterで扱う思考停止JavaBeansパターンから卒業して数ヶ月、
ある日ふと最新のmasterをpullすると、そこには機能拡張で大きくなったEmployeeクラスがいました。
@RequiredArgsConstructor // finalのフィールドだけ引数に取るコンストラクタ
public class Employee {
private final Long id;
private final int age;
private final int salary;
private final int timesOfTransfer;
private final String name;
private final String department;
private final String loginId;
private final String currentProject;
private String maidenName;
}
大きくなったな・・・
public class EmployeeUtil {
public static Employee createEmployee() {
Employee employee = new Employee(3L, 30, 300000, 2, "hoge三郎", "moge部", "USER01234", "bazプロジェクト");
}
}
宣言大変になったな・・・
IDEならどのフィールドに何を入れているか補完してくれるけど、
それでも書いてて気持ちいいかと言われると微妙で、読む側も正直しんどいですね。
Builderパターン
Builderパターンを使えばある程度見やすくなります。
Lombokの@Builder
アノテーションをクラスに付与すると、
@Builder
public class Employee {
EmployeeBuilder
というインナークラスとbuilder()
というstaticメソッドが生成されます。
builder()
メソッドが返却するEmployeeBuilder
のインスタンスは、付与したクラスのフィールドと同名のメソッドを持ち、
それぞれのメソッドに値を渡した後、build()
することでEmployee
のインスタンスを生成します。
public class EmployeeUtil {
public static Employee createEmployee() {
Employee employee =
Employee.builder().id(3L).age(30).salary(300000).timesOfTransfer(2).name("hoge三郎")
.department("moge部").loginId("USER02134").currentProject("bazプロジェクト").build();
}
}
でも実は何も設定しなくてもbuild()
できるので今回の記事の趣旨においては無意味です。
finalのフィールド設定しないとインスタンス生成できないBuilder実装、前にどこかで見たんですが探せませんでした。。。
public class EmployeeUtil {
public static Employee createEmployee() {
Employee employee = Employee.builder().build();
System.out.println(employee);
// Employee(id=null, age=0, salary=0, timesOfTransfer=0, name=null, department=null, loginId=null, currentProject=null)
// 何も入ってないのでやはり人間は愚か
}
}
もっと最強の実装パターンがあったらぜひ教えてください。
参考文献
Effective Java 第3版
モダンなJavaの書き方。Immutable Java、Null安全を考えてみる。
LombokのBuilderパターン解説