Javaのカレンダー | Advent Calendar 2021の6日目の記事です
概要
手続き型のこれを
@GetMapping("/items")
public List<ItemDto> get(@RequestParam(name = "name", defaultValue = "") String name) {
// 入力チェック
if (name.length() <= 10) {
throw new IllegalArgumentException("name");
}
// 商品をDBから検索
List<ItemData> itemDataList = repository.findByNameLike("%" + name + "%");
List<ItemDto> result = new ArrayList<>();
for (var itemData : itemDataList) {
var itemDto = new ItemDto();
itemDto.setId(itemData.getId());
itemDto.setCategory(itemData.getCategory());
itemDto.setName(itemData.getName());
itemDto.setPriceIncludingTax(itemData.getPrice() * 110 / 100); // 消費税を計算
result.add(itemDto);
}
return result;
}
関数型のこれにする話
@GetMapping("/items")
public List<ItemDto> get(@RequestParam(name = "name", defaultValue = "") String name) {
return validate(name).stream() // 入力チェック
.flatMap(this::getItems) // 商品をDBから検索
.map(mapper::toDto) // DTOに変換
.collect(Collectors.toList()); // List に変換
}
// ...
Javaを関数型で書くと、行数が短くなるという話がありますが、今回のサンプルではそうはならなかったので、一部抜き出して、短く見せている部分もあります
(なので記事の最後に全体像を貼り付けておきます)
説明
Spring Boot を使った REST API を関数型で書いたらこうなるという例(上のコード)です
Java8以降であれば、関数型インターフェイスと Stream API を組み合わせることで、例のような関数型プログラミングができます
メリットは、何でしょうか?
アプリケーションの要求と書き手のスキルがマッチすれば、参照透過性の高い堅牢なプログラムになると信じていますが...
既存の冗長な手続き型のプログラムを関数型にリファクタリングするというだけでも新しい発見がありそうです
最近では、Kotlin やりたい。 WebFlux やりたい。Scala / Clojure を業務で使ってみたい、みたいな声も聞きますが(実際には聞いたことはありません)、まずは手元のいい意味でレガシーな業務コードを関数型に書き換えて、キリッっとしてみていはいかがでしょうか?
自分も Javaや関数型に詳しいというわけでなく、できればいいな程度の気持ちでこの記事を書いています
また、実戦経験にも乏しいため、中途半端なコードになってしまっていたら申し訳ないです
ポイント
関数型でプログラミングするために気をつけること(これをやれば必ず関数型になるというものではない)をあげてみます
- 1対1の変換には
map
、1対Nの変換にはflatMap
を使ってメソッドをつなげる - メソッドの抽出を繰り返して、メソッドを最小単位に分解し、可能な限りメソッド参照を使ってアクセスする
- メソッドの行数は5行という目安があるようです。が、すぐに破られるでしょう
- 例外は、非検査例外(RuntimeExceptionの派生)を使う
- 検査例外はラムダ式では扱えないので、@SneakyThrows を使って回避する
- ラムダでの例外の扱いには諸説ありますが、@SneakyThrows は javac のチェックをすり抜けて、元の例外を投げているという話もあるので、邪道といわれてしまうかもしれません
- これが参考になるかも Hide Checked Exceptions with SneakyThrows
- 例外は、@RestControllerAdvice を使って REST API の大元でハンドリングする
- 検査例外はラムダ式では扱えないので、@SneakyThrows を使って回避する
- DTOにロジックを書かない
- DTO(Data Transfer Object) を本来の意味のものにしてあげる
- 書いても static メソッドの置き場所に困ったときにする
- 可能であれば メソッドを static にする
- static であれば、少なくともメンバ変数との依存がないことが保証され、コードが理解しやすい
妥協ポイント
- 副作用のない関数が必ずしも書けると思わない
- どうしてもメソッド間でデータを持ちわまりたいときに、引数を変更して返却してしまう自分がいる
- 例外の扱いは雑になる
- 検査例外の投げ直し(@SneakyThrows)、呼び出し元でのエラーハンドリング(@RestControllerAdvice)がオススメなので、回復可能な例外などを個別に手当しない(してもよい)が第一となる
- 多少の手続き型は残す(る)
- やっぱり、手続き型で書くと作業が捗るよね、なんてこともある
付録
概要で省略したコードの全体像です
@RestController
@RequiredArgsConstructor
public class SampleFunctionalController {
private final ItemRepository repository;
private final ItemMapper mapper;
@GetMapping("/items")
public List<ItemDto> get(@RequestParam(name = "name", defaultValue = "") String name) {
return validate(name).stream() // 入力チェック
.flatMap(this::getItems) // 商品をDBから検索
.map(mapper::toDto) // DTOに変換
.collect(Collectors.toList()); // List に変換
}
// 入力チェック
public Optional<String> validate(String name) {
if (name.length() > 10) {
throw new IllegalArgumentException("name");
}
return Optional.of(name);
}
// 商品をDBから検索
public Stream<ItemData> getItems(String name) {
return repository.findByNameLike("%" + name + "%");
}
}
@Component
class ItemMapper {
public ItemDto toDto(ItemData itemData) {
var itemDto = new ItemDto();
itemDto.setId(itemData.getId());
itemDto.setCategory(itemData.getCategory());
itemDto.setName(itemData.getName());
itemDto.setPriceIncludingTax(itemData.getPrice() * 110 / 100); // 消費税を計算
return itemDto;
}
}