Java Advent Calendar 2022の8日目の記事です。指摘コメントよろしくお願いします。
Javaに限らず、interfaceは使い道を理解することは難しいです。この記事では、私が普段使用しているinterfaceの使い方実例を記すことで、情報共有を行うことを目的としています。なお、ソースコードはイメージですので動かない可能性があることは御了承ください。
環境と背景
- Java
- 17
境界としての使い方
interface(境界面)という言葉の意味どおりの使い方です。
MVCフレームワークだと伝わりやすい境界面がないので、アーキテクチャとしてはポートアンドアダプターを理解していると、境界面を理解しやすいと思います。
アクセスの入り口となるController
, 実際に処理を行うService
, DBやRedis等のアプリケーションの外側に保存されたデータ操作を行うRepository
等々が挙げられます。この境界でinterfaceを用意することで、実装クラスを差し替えても影響することはありません。
public interface UserService { public User getUser(String id); } public interface UserRepository { User getUser(String id); } @RequiredArgsConstructor public class UserServiceImpl implements UserService { // 後でRepositoryが別実装クラスになってもServiceの修正は不要。 private final UserRepository repository; public User getUser(String id){ repository.getUser(id); } } @RequiredArgsConstructor public class UserRepositoryImpl implements UserRepository { private final JdbcTemplate jdbcTemplate; User getUser(String id) { // RDBにアクセスする } }
ただし、アプリケーションを作成していくうえで、Service
等の実装クラスを差し替えることほとんどないです。DBを切り替える場合でも、実装クラスのRepository
をすべて修正する必要があるので、差し替えではありません。
実装クラスの差し替えがあるとすれば、外部サービスの使用箇所です。たとえば、メール送信機能です。今まではメール送信には社内の別システムを使っていたが、AWS SESを使用するようにしたとします。その場合、呼び出し側の機能としては、メール送信機能のinterfaceを経由して呼ぶことで、実装が変わったとしても意識することはありません。
public interface メール送信機能 {} public class 自社メールシステム implements メール送信機能 {} public class AwsSes implements メール送信機能 {}
なお、この記事の本題とは外れますが、A機能では自社メールシステム、B機能ではAWS SESを使用してメール送りたい等々の同一interfaceの実装クラスを適切にDIしたいときには、Toshiaki Makiさんの記事が参考になります。
振る舞いとしての使い方
特定の機能を保持していることをinterfaceで表現できます。私の場合はバリデーションを行うクラスにValidator
というinterfaceを付与することが多いです。Validator
には必要なdefaultメソッドを持たせています。
次の例は、Controller
がシステム権限ではない場合にエラーとするコードです。
public interface Validator { // システム権限では無かったらエラー default void systemAdminValidate(User user) { if (!user.role.equals("ROLE_SYSTEM_ADMIN")) { throw new RuntimeException("権限不足"); } } } public interface SampleApi { public ResponseEntity<Void> insert(User user, SampleDto dto); } public class SampleController implements SampleApi, Validator { public ResponseEntity<Void> insert(User user, SampleDto dto){ // interfaceのdefaultメソッドのバリデーション systemAdminValidate(user); return ResponseEntity.ok().build(); } }
staticメソッドと同じ働きをしますが、interfaceで実装することによってクラスがもつ役割を伝えられる表現の幅が広がります。継承とは異なり、interfaceにはUser
等の状態を保持できないのも好みです。また、interfaceは複数実装できます。先ほどの境界としてのinterfaceを邪魔することなく利用できるのもよい点です。
なお、メソッドやフィールドが一切定義されていないインタフェースをマーカーインタフェースと呼びます。私はマーカーインタフェースを有効に使えたことはありません。
また上記例ではinterfaceを使っていますが、アノテーションで表現した方がmeta情報に渡せる分、より適切だと考えていますのであくまでイメージとしてとらえてください。
public class SampleController implements SampleApi { // システムアドミン権限だけしか実行できないようにする自作Roleバリデーション @Role(values = [Roles.SYSTEM_ADMIN]) public ResponseEntity<Void> insert(User user, SampleDto dto){ return ResponseEntity.ok().build(); } }
別の例としては、データ登録時にエンティティの登録時間と登録者、更新時間と更新者を更新したい、更新時にエンティティの更新時間と更新者を更新したい等があったとします。
public class User { String id; String name; LocalDateTime 登録時間; String 登録者; LocalDateTime 更新時間; String 更新者; }
interfaceで定義するとパラメータとして型を渡せます。登録時、更新時用のinterfaceを定義し、User
クラス側で受け取って更新するようにすると多少簡潔になったコードを書けます。
良い例ではないので恐縮ですが、クラスとしての振る舞いだけでなく、interfaceとしての振る舞いも定義できるといったことを示したかったものです。1度認識すると、少しだけ見えてくる世界が変わってくるので、使う機会は難しいですがぜひとも覚えてください。
public interface 登録時更新項目 { LocalDateTime get登録時間(); String get登録者; LocalDateTime get更新時間(); String get更新者(); } public interface 更新時更新項目 { LocalDateTime get更新時間(); String get更新者(); } public class ユーザ登録リクエスト implements 登録時更新項目 { // interfaceのコード実装 } public class ユーザ更新リクエスト implements 更新時更新項目 { // interfaceのコード実装 } public class ユーザサービス { public class insert(ユーザ登録リクエスト request){ // ユーザ登録リクエストを渡しているが、登録時更新項目でもある user.update登録時更新項目(request); } public class update(ユーザ更新リクエスト request){ user.update更新時更新項目(request); } } public class User { // 大元の型はユーザ登録リクエストだが、登録時更新項目interfaceとしての表現ができる public void update登録時更新項目(登録時更新項目 request) { set登録時間(request.get登録時間()); set登録者(request.get登録者()); ... } public void update更新時更新項目(更新時更新項目 request){ set更新時間(request.get更新時間()); set更新者(request.get更新者()); } }
ストラテジーパターン
interfaceをより活用する例として、ストラテジーパターン(strategy pattern)があります。
詳細は以前私がブログにしているので、詳細を知りたい方は上のリンクをクリックしてください。
ざっくり記載すると、interfaceを定義し、それぞれの実装クラスを用意します。適切な実装クラスに振り分けるコードを記載し、適切なinterfaceの実装クラスに処理を委譲させるデザインパターンです。ただ、有用ではあるものの、ストラテジーパターンを導入することによって複雑になってしまいます。また、委譲したコードの不具合が見つかると横展開がかなり面倒です。
多少の複雑性が増しても、それを上回るメリットがありそうな時に使用してください。
defaultメソッド
デフォルト値を渡すメソッド
Javaはデフォルト値を渡すことができないため、メソッドにデフォルト値を渡したい場合はオーバーロードしてパラメータを渡すしかありません。
interfaceでメソッドを記載してしまうと、実装側ですべてのメソッドを用意する必要があります。もしデフォルト値が固定であれば、defaultメソッドで定義しておくと、クラス側で実装するものが少なくて済みます。
public interface メール送信機能 { String FROM = "from@example.com"; String CC = "cc@example.com"; default void mail(String to){ mail(to, FROM); }; default void mail(String to, String from){ mail(to, from, CC); }; void mail(String to, String from, String cc); } public class 自社メールシステム implements MailFunction { // パラメータ1つ、2つはdefaultメソッドで定義 public void mail(String to, String from, String cc) {} } public class AwsSes implements MailFunction { // パラメータ1つ、2つはdefaultメソッドで定義 public void mail(String to, String from, String cc) {} }
実装クラスをライブラリが用意するパターン(MyBatis)
MyBatis
では、interfaceだけを定義し、アノテーションやXMLで実装内容を記載します。実装クラスはライブラリ側が作成します。そのため、defaultメソッドの存在を知らないと、ガードできずにエラーを吐くことがあります。
たとえば、配列でパラメータを受け取って配列数分insertするメソッドがあります。そのメソッドではパラメータを元にSQLを組み立てていたため、データが0件だと異常なSQLが生成され、エラーとなってしまいました。そのため、defaultメソッドでデータが0件だった場合に、安全に処理するように変更しました。
ただし、interfaceでも通常とdefaultメソッドは同一パラメータで定義できない為、呼び出し元に影響をなくし、内部で安全に処理するようにしていました。
public interface SampleMapper { // 本当に実行したいメソッドはわざとオーバーロードさせて、余計なパラメータを受け取るようにする @Insert int insertList(List<Sample> samples, boolean dummy); // Mapperを使う側に対してはこっちを使ってもらう default int insertList(List<Sample> samples) { if (samples.isEmpty()) return 0; return insertList(samples, false); } }
他にも、配列で受け取って、内部的には一件ずつ処理をするような処理も作成できます。
public interface SampleMapper { @Update int update(Sample samples); default int updateList(List<Sample> samples) { int result = 0; for (var sample: samples) { result += update(sample); } } }
オススメしない使い方
最後にオススメしない使い方を記載します。ジェネリクスを使用したinterfaceは、実装クラスを自動生成しない限りはオススメしないです。
Controller
で受け取ったdtoとデータベースのエンティティを変換するDtoConverter<T, R>
interfaceを用意します。
public interface DtoConverter<T, R> { R toEntity(T dto); T toDto(R entity); }
意図は非常に伝えやすいですし、使用する側も違和感なく使用できます。
public class SampleDtoConverter implements DtoConverter<SampleDto, Sample> { Sample toEntity(SampleDto dto) { return // 変換処理。 } SampleDto toDto(Sample entity) { return // 変換処理。 } } @RequiredArgsConstructor public class SampleController { private final DtoConverter<SampleDto, Sample> converter; }
問題なくDIもできます。問題点としては、IntelliJ IDEA(2022.2.3バージョン時点)では、目的のジェネリクスを持ったコードに対してコードジャンプができないというところです。
Todo: 301
Javaの構文としても問題なし、動作も問題なく行われる、ただしIDEがサポートしてくれません。ですので、「実装コードを自動生成しない限り」はオススメしないと表現しました。そのうち対応されるかもしれませんが、interfaceで抽象化したうえでさらにジェネリクスで抽象化しているので、相当要望が上がらない限りは対応しないと思います。少なくとも、ちょっと調べただけでは、IntelliJ IDEAの不具合や要望を伝えるサイトのYou Trackではissueは見つかりませんでした。
ソースコード
特にありません。
終わりに
私もinterfaceをマスターしているわけではありませんが、この記事を通じてinterfaceを使用してみようと行動する方がいたら幸いです。