じゃんけんアドベントカレンダー の 22 日目です。
- 初回 ... 【Day 1】とりあえず 1 クラスに全部書く【じゃんけんアドカレ】
- 前回 ... 【Day 21】CLI アプリケーションにおける Controller の違和感を解消【じゃんけんアドカレ】
今回は 10 日目の記事 で登場した、Transaction 型をキャストしてしまっている問題を解消できるかを検討していきます。
「キャストを使っている箇所をジェネリクスによって型安全にする」といった内容になります。
該当箇所
現状のコードでは、JankenMySQLDao などの DAO 内部で使っている SimpleJDBCWrapper が、Transaction 型を受け取って JDBCTransaction 型にキャストしています。
public class SimpleJDBCWrapper {
public <T> List<T> findList(Transaction tx,
RowMapper<T> mapper,
String sql,
Object... params) {
val conn = ((JDBCTransaction) tx).getConn();
:
}
:
public <T> void insertAll(Transaction tx,
InsertMapper<T> mapper,
String tableName,
List<T> objects) {
:
val conn = ((JDBCTransaction) tx).getConn();
:
キャストは実行時までエラーが発生するか分からない処理であり、可能な限り使うべきではありません。
この記事では、上記のキャストによる実行時エラーの可能性をなくすことができるかを考えていきます。
Transaction 型を使うサンプルコード
上記の Transaction 型を使うサンプルコードは以下のようになります。
ServiceLocator.register(TransactionManager.class, JDBCTransactionManager.class);
ServiceLocator.register(JankenDao.class, JankenMySQLDao.class);
val tm = ServiceLocator.resolve(TransactionManager.class);
val jankenDao = ServiceLocator.resolve(JankenDao.class);
tm.transactional(tx -> {
jankenDao.count(tx);
});
ここで使っている TransactionManager インタフェースは、現状以下のようになっています。1
public interface TransactionManager {
<U> U transactional(Function<Transaction, U> f);
void transactional(Consumer<Transaction> c);
}
TransactionManager が Transaction 型を固定で使っているため、JDBCTransaction 型にキャストする必要が出てしまうわけです。
型パラメータを追加
そこで、TransactionManager が Transaction 型を指定している箇所を、Transaction 型を継承した各種の型を返せるようにします。
public interface TransactionManager<T extends Transaction> {
<U> U transactional(Function<T, U> f);
void transactional(Consumer<T> c);
}
これで、実装クラスは Transaction 型ではなく JDBCTransaction 型を扱うことができるようになりました。
public class JDBCTransactionManager implements TransactionManager<JDBCTransaction> {
@Override
public <T> T transactional(Function<JDBCTransaction, T> f) {
:
}
@Override
public void transactional(Consumer<JDBCTransaction> c) {
:
}
}
この変更に伴い、DAO 側も Transaction 型を継承した型を扱えるように変更します。
public interface JankenDao<T extends Transaction> {
List<Janken> findAllOrderByPlayedAt(T tx);
Optional<Janken> findById(T tx, String id);
long count(T tx);
void insert(T tx, Janken janken);
}
DAO の実装クラスは以下のように JDBCTransaction 型を受け取るようになります。
public class JankenMySQLDao implements JankenDao<JDBCTransaction> {
:
@Override
public List<Janken> findAllOrderByPlayedAt(JDBCTransaction tx) {
val sql = SELECT_FROM_CALUSE + "ORDER BY played_at";
return simpleJDBCWrapper.findList(tx, rowMapper, sql);
}
:
これで準備が完了しました。
あとは TransactionManager や DAO を使うアプリケーション層で型安全にコードが書ける方法を考えていきます。
- ServiceLocator を使って無理やり取り出す場合
- Transaction 型を扱うクラスをとりまとめるクラスを作り、それを ServiceLocator に登録する場合
- DI を使う場合
の 3 つに分けて検討していきます。
※ 以後の検証コードはテストコードとして書いています。コードの全体像は GitHub の TransactionCastingSample.java を参照ください
1. ServiceLocator を使って無理やり取り出す場合
まずはじめに、ServiceLocator を使って無理やり TransactionManager や DAO を取り出す場合です。
ServiceLocator を使う場合、取り出して使う Service 側に <T extends Transaction> という型パラメータを宣言して使えばコンパイルエラーは発生しません。
@Test
public void ServiceLocatorに適切に登録すれば例外が発生しない() {
ServiceLocator.register(TransactionManager.class, JDBCTransactionManager.class);
ServiceLocator.register(JankenDao.class, JankenMySQLDao.class);
val service = new SampleServiceResolvingTransactionManagerAndJankenDao<>();
try {
service.run();
} catch (Throwable e) {
// 例外が発生した場合はテスト失敗
fail(e);
}
}
:
private class SampleServiceResolvingTransactionManagerAndJankenDao<T extends Transaction> {
private TransactionManager<T> tm = ServiceLocator.resolve(TransactionManager.class);
private JankenDao<T> jankenDao = ServiceLocator.resolve(JankenDao.class);
public void run() {
tm.transactional(tx -> {
jankenDao.count(tx);
});
}
}
ですが、これは実はコンパイルエラーが発生しないだけで、ServiceLocator への登録が間違っていると実行時にエラーになります。
このことは以下のテストで確認できます。
@Test
public void ServiceLocatorへの登録が不適切だと実行時に例外が発生する() {
ServiceLocator.register(TransactionManager.class, JDBCTransactionManager.class);
// JankenMySQLDao を登録すべきなのに JankenNonMySQLDao を登録
ServiceLocator.register(JankenDao.class, JankenNonMySQLDao.class);
val service = new SampleServiceResolvingTransactionManagerAndJankenDao<>();
assertThrows(ClassCastException.class, service::run);
}
つまり、この方法では自分でキャストを書かなくなっただけで、結局実行時に ClassCastException が発生するようになってしまっているのです。
そもそも ServiceLocator を使っている時点で実行エラーの可能性をなくすことはできませんが、もう少し工夫することができないか、次の案を考えてみます。
2. Transaction 型を扱うクラスをとりまとめるクラスを作り、それを ServiceLocator に登録する場合
次は、Transaction 型を扱うクラスをとりまとめるクラスを作る場合です。
具体的には、以下のインタフェースと実装クラスを作成します。
interface TransactionalClassFactory<T extends Transaction> {
TransactionManager<T> tm();
JankenDao<T> jankenDao();
}
@NoArgsConstructor
class MySQLTransactionalClassFactory implements TransactionalClassFactory<JDBCTransaction> {
@Override
public TransactionManager<JDBCTransaction> tm() {
return new JDBCTransactionManager();
}
@Override
public JankenDao<JDBCTransaction> jankenDao() {
return new JankenMySQLDao();
}
}
これを使うと、以下のように型安全にコードが書けるようになります。
@Test
public void Transaction型を扱うクラスをとりまとめるクラスをServiceLocatorに登録すれば型安全に取り出せる() {
ServiceLocator.register(TransactionalClassFactory.class, MySQLTransactionalClassFactory.class);
val service = new SampleServiceResolvingTransactionalClassFactory<>();
try {
service.run();
} catch (Throwable e) {
// 例外が発生した場合はテスト失敗
fail(e);
}
}
private class SampleServiceResolvingTransactionalClassFactory<T extends Transaction> {
private TransactionalClassFactory<T> factory = ServiceLocator.resolve(TransactionalClassFactory.class);
private TransactionManager<T> tm = factory.tm();
private JankenDao<T> jankenDao = factory.jankenDao();
public void run() {
tm.transactional(tx -> {
jankenDao.count(tx);
});
}
}
この実装であれば、TransactionalClassFactory インタフェースの実装クラスに、全てのメソッドで Transaction 型の同じサブクラスを扱うことが強制され、型安全になります。
どこで聞いたか忘れてしまいましたが、「ソフトウェア設計における問題は、1 つ何かをはさむことで解決できる」といった言葉があったと思います。
今回はまさに、TransactionalClassFactory というクラスを 1 つはさんだことで ClassCastException が発生しないようにできました。
しかし、この実装にも難点があります。
DAO が増えるたびに TransactionalClassFactory インタフェースと実装クラスにメソッドを追加する必要が出てしまうのです。
テストさえしていれば ClassCastException が発生する可能性はあまり高くないので、あえてこのような工夫は入れない、という選択の方が現実的かもしれません。
3. DI を使う場合
最後に、DI を使う場合を考えます。
DI といっても、DI コンテナは使わずにコンストラクタで依存を解決するようにするだけです。
コードは以下のようになりました。
@Test
public void DIなら型安全に扱える() {
val tm = new JDBCTransactionManager();
val jankenDao = new JankenMySQLDao();
val service = new SampleService<>(tm, jankenDao);
try {
service.run();
} catch (Throwable e) {
// 例外が発生した場合はテスト失敗
fail(e);
}
}
@AllArgsConstructor
private class SampleService<T extends Transaction> {
private TransactionManager<T> tm;
private JankenDao<T> jankenDao;
public void run() {
tm.transactional(tx -> {
jankenDao.count(tx);
});
}
}
コードは型安全にコンパイルされ、実行できました。
Service に <T extends Transaction> という型パラメータをつける必要があるだけなので、他の 2 つの方法よりも簡単に実装できています。
DI コンテナを使っても動くかは未検証ですが、Java ではジェネリクスの型パラメータは実行時には消えているので、おそらく動くのではないかと思います。
まとめ
今回は、キャストを使っている箇所をジェネリクスで型安全にする方法を検討してきました。
ジェネリクスによって、キャストを使わず型安全なコーディングが可能になりました。
Web アプリケーションなどを実装する際に、ジェネリクスを使ったクラスやメソッドを定義することは多くありません。
ですが、フレームワークやライブラリを実装したり、それらに準じる抽象的な機能を実装する際は、ジェネリクスが有効な場合が少なくありません。
ジェネリクスは最初は少し難しいですが、身に付けると非常に便利です。
じゃんけんアプリケーションの場合、実際には ServiceLocator や DI コンテナを使っているため、実行時エラーの可能性は 0 にはできません。
型安全なコードを書くことも大事ですが、テストによって実行時エラーが発生しないことを保証することも重要です。
次回のテーマ
次回は 12 日目の記事 で整理したドメインモデルをよりブラッシュアップしてみようと思います。
少し複雑なまま残っている「じゃんけんする」メソッドがより整理されることを目指します。
それでは、今回の記事はここまでにします。最後まで読んでくださりありがとうございました。
じゃんけんアドベントカレンダー に興味を持ってくださった方は、是非購読お願いします。
現時点のコード
コードは GitHub の この時点のコミット を参照ください。
-
この記事に登場する TransactionManager クラスは、Spring の提供する TransactionManager クラスではない自作のクラスです ↩