はじめに
最近「防御的プログラミング」という言葉を記事を読んでいて知ったのですが、
どうもサンプルコードに手続型プログラミング的なコードばかりでてきて
納得がいかないので、オブジェクト指向プログラミングやSpring AOP、Lombokなどを用いて
防御的なプログラムを書いてみようと思います。
概念のおさらい
具体的なコードに触れる前に
「防御的的プログラミング」と「契約プログラミング」の概念をおさらいしておきましょう。
防御的プログラミング
現場で役立つシステム設計の原則1で説明されている防御的プログラングの定義は以下の通りです。
「サービスを提供する側は、利用する側が何をしてくるかわからない」という
前提でさまざまな防御的なロジックを書きます。
利用する側も、提供側が何を返してくるかわからないという前提で、戻ってきた値のnull チェックや、
さまざまな検証のコードを書きます。
契約プログラミング
一方同一書籍より、契約による設計とも呼ばれる契約プログラミングの定義は以下のようになっております
サービスを利用する側と、サービスを提供する側とで、
サービス提供の約束ごとを決め、設計をシンプルに保つ技法を契約による設計と呼びます。
オブジェクト指向でソフトウェアの信頼性を高めるためのクラス設計の基本原則のひとつです。
リファクタリングしてみる
概念がをおさらいしたところで、今度はよく見かけるような
防御的プログラミングのコードをリファクタリングをおこなっていきます。
例題として扱うケースは以下の通りです。
- ユーザーのIDと住所情報をDBに保存する。
- ユーザーID
-
Userテーブル
に存在するものでければならない
-
- 住所情報
- データ内容
- 郵便番号
- 住所
- 郵便番号の町域と住所の町域が一致しなければならない
- データ内容
- ユーザーID
リファクタリング前
防御的プログラミングで記述すると以下のようになるかと思います
JavaやSpring Bootを前提としていないコードを参考にしているので
その辺のおかしな部分もついてで修正することにします。
@RestController
public class UserAdderController {
@Autowired
UserService userService;
@Autowired
UserAdderService adderService;
@PostMapping("/useradder/{uid}/save")
public String save(@PathVariable("uid") Integer uid,
@RequestParam(value = "postcode") String postCode,
@RequestParam(value = "adder") String adder) throw Exception {
// null check
if (uid == null) throw new IllegalArgumentException("uidが空です");
if (StringUtil.isBlank(postCode)) throw new IllegalArgumentException("郵便番号が空です");
if (StringUtil.isBalnk(adder)) throw new IllegalArgumentException("住所が空です");
// uidの存在確認
if (!userService.exist(uid)) {
throw new IllegalStateException("userが存在しません");
}
// 住所と郵便番号が一致することを確認
String postCodeAdder = this.getAdderFromPostCode(postCode);
if (adder.startWith(postCodeAdder)) {
throw new IllegalStateException("住所もしくいは郵便番号が不正です");
}
try {
adderService.save(uid, postCode, adder);
} catch (HOGEException e) {
// 何らかの処理
.......
} catch (FUGAException e) {
// 何らかの処理
.......
}
~~~以下省略(今後も例外ハンドリングがつづくイメージ)~~~
return new String.EMPTY;
}
}
@Service
public class UserAdderService throws HOGEException FUGAException ...... {
public void save(Integer uid, String postCode, String adder) {
if (uid == null) throw new IllegalArgumentException("uidが空です");
if (StringUtil.isBlank(postCode)) throw new IllegalArgumentException("郵便番号が空です");
if (StringUtil.isBalnk(adder)) throw new IllegalArgumentException("住所が空です");
~~~~以下省略~~~~
}
}
リファクタリング
1. Spring Bean Validationを使う
SpringBootはValidation機能を提供しており、値を一括でバリデーションかけられるのでそちらを用います。
そうすることで記述量を増やさずに、外から来た不正な値を弾くことができるので
不要なロジックが移譲され、可読性が向上します。
バリデーションを用いる際にはクラスにまとめてしまった方が楽なのでまとめてしまいます。
@PostMapping("/useradder/{uid}/save")
public String save(@PathVariable("uid") Integer uid,
@RequestBody @Validated UserAdderInfo adderinfo
Errors errors) throw Exception {
@Data
public class UserAdderInfo {
@NotNull
@Pattern(regexp = "^\\d{3}-\\d{4}$")
String postCode;
@NotNull
@Size(min = 20, max = 120)
String address
}
2. パラメーターのNullチェックはLombokにまかせる
さて次はサービスクラスを見ていきます。ここでもnullチェックをしていますが、
いらない気がしなくもないです。契約プログラミングで十分な気もします。
ただ、後に変な使われ方をしてバグを起こされるのも嫌なので念のため残しておきたい気もします。
そこでビジネスロジックを邪魔しない様にLombokを用いてしていきましょう。
@Service
public class UserAdderService throws HOGEException FUGAException ..... {
public void save(@NonNull Integer uid,
@NonNull String postCode,
@NonNull String adder) {
~~~~以下省略~~~~
}
}
3. オブジェクトにラップして渡す
Controllerで受け取った値以外はBeanValidationは使用できません。
なので複数データを渡す場合は、クラスにデータを意味のある形でまとめることを検討しましょう。
クラスをImmutableにし、バリデーションロジックをstatic factoryメソッドやbuilderクラスに内包することで
ビジネスロジックを汚さずに済みますし、フィールドに関連するロジックがカプセル化されているので変更も容易です。
4. 契約プログラミングをつかう
いちいち値を細かくチェックする必要があるかと言われると正直疑問です。
Contorllerの様な外部から値が渡される場合以外は、利用する側と提供する側で決まりを作り
それをもとにクラスを設計し、ビジネスロジックをシンプルにするといった手段を検討しましょう。
現場で役立つシステム設計の原則1によると以下が基本的な決まりになります。
- null を渡さない/ null を返さない
- 状態に依存する場合、使う側が事前に確認する
- 約束を守ったうえでさらに異常が起きた場合、例外で通知する
5. 検証する例外は最低限
過剰に例外をキャッチする必要はありません。極端な例を出すと、
毎回NullPointExceptionをキャッチしようとして
RuntimeExceptionをハンドリングする必要はないのです。
ロジックに必要な例外のみを検査し、それ以外のものは
ControllerAdvanceでハンドリングしてエラーコードを返しましょう。
そうすることで以下のロジックをコードから削除できます
//try {
// adderService.save(uid, postCode, adder);
//} catch (HOGEException e) {
// // 何らかの処理
// .......
//} catch (FUGAException e) {
// // 何らかの処理
// .......
//}
adderService.save(uid, postCode, adder);
6. ログを埋め込む
ここまで想定外の値からシステムを守ることを考えてきましたが、
最初から全ての例外や不具合を想定するのは不可能です。
そのため後に障害が発生した場合に不具合をすぐに検知できる様にすることも
忘れてはいけません。
ログをしっかり出すことで将来的な不具合に備えましょう。
// uidの存在確認
if (!userService.exist(uid)) {
this.logger.error("userが存在しません")
throw new IllegalStateException("userが存在しません");
}
7. ビジネスロジックをプレゼンテーション層でハンドリングしない
タイトルとは内容が逸れますが、uidが存在しているかの確認や、住所と郵便番号の一致の確認は
バリデーションというよりビジネスロジックに該当する気がするので
諸説ありそうですが一旦サービスクラスに移しておきます。
@Service
public class UserAdderService {
public void save(@NotNull Integer uid,
@NotNull String postCode,
@NotNull String adder) {
// uidの存在確認
if (!userService.exist(uid)) {
throw new IllegalStateException("userが存在しません");
}
// 住所と郵便番号が一致することを確認
String postCodeAdder = this.getAdderFromPostCode(postCode);
if (adder.startWith(postCodeAdder)) {
throw new IllegalStateException("住所もしくいは郵便番号が不正です");
}
~~~~~以下省略~~~~~~~~
}
}
まとめ
だいぶビジネスロジックがすっきりしてコードがシンプルになったのではないでしょうか?
今回はあまりオブジェクト指向は出番がありませんでしたが、
フレームワークやライブラリの力を借りることで
ビジネスロジックとは関係のないノイズをコードから削除することがきました
今後ロジックが複雑になるにつれてデータをオブジェクトとして管理すると
ビジネスロジックがシンプルになるので、途中のリンクの記事にも目を通してみてください。
最後に修正後のコードを載せておきます。
@RestController
public class UserAdderController {
@Autowired
UserService userService;
@Autowired
UserAdderService adderService;
Logger loger = new Logger();
@PostMapping("/useradder/{uid}/save")
public String save(@PathVariable("uid") Integer uid,
@RequestBody @Validated UserAdderInfo adderinfo
Errors errors) throw Exception {
adderService.save(uid, postCode, adder)
return String.EMPTY;
}
~~~~~以下省略~~~~~~~~
}
@Data
public class UserAdderInfo {
@NotNull
@Pattern(regexp = "^\\d{3}-\\d{4}$")
String postCode;
@NotNull
@Size(min = 20, max = 120)
String address
}
@Service
public class UserAdderService throw {
public void save(@NotNull Integer uid,
@NotNull String postCode,
@NotNull String adder) {
// uidの存在確認
if (!userService.exist(uid)) {
throw new IllegalStateException("userが存在しません");
}
// 住所と郵便番号が一致することを確認
String postCodeAdder = this.getAdderFromPostCode(postCode);
if (adder.startWith(postCodeAdder)) {
throw new IllegalStateException("住所もしくいは郵便番号が不正です");
}
~~~~以下省略~~~~
}
}