はじめに
今回の記事はSpring Frameworkにまつわる非常に基本的な要素であるDependency Injection(DI)に関することです。
Springのようなアプリケーションフレームワークでは、DIコンテナに登録したBeanをアプリケーションに注入(Injection)していくわけですが、その最初のところで躓いてしまったので、備忘録もかねて記事にしておきます。
なお、前半はDIについて改めて振り返ってみたという内容になっています。アノテーションベースConfigurationの話は後半です。
Dependency Injection(DI)について
Dependency Injectionはクラスとクラスの結合度を下げるための機構で、以下のような課題を解決します。
結合度の高い実装例
最初に、DIを使わないで実装するアプリの例を示します。
これはアカウント情報を暗号化してファイルに保存する処理を想定しています。AccountRecorderクラスの中でFileRecorderやSimpleEncrypterのインスタンスを作成しているので、これらのクラスが存在しなければAccountRecorderをテストすることができません。
package com.example.demo.monolithic;
/**
* アカウント情報記録クラス
*/
public class AccountRecorder {
/** アカウント情報 */
private Account account;
/** フィル保存クラス */
private FileRecorder fileRecorder;
/** 情報暗号化クラス */
private SimpleEncrypter encrypter;
public AccountRecorder(String filename) {
// 与えられたファイル名を付与してファイル保存クラスをインスタンス化
fileRecorder = new FileRecorder(filename);
// 暗号化クラスをインスタンス化
encrypter = new SimpleEncrypter();
}
public void save(Account account) {
String encrypted = encrypter.encrypt(account.toString());
fileRecorder.save(encrypted);
}
}
package com.example.demo.monolithic;
public class Account {
private String username;
private String password;
public Account(String username, String password) {
super();
this.username = username;
this.password = password;
}
@Override
public String toString() {
StringBuilder sb = new StringBuilder();
sb.append(username);
sb.append(":");
sb.append(password);
return sb.toString();
}
}
package com.example.demo.monolithic;
import java.io.BufferedWriter;
import java.io.FileWriter;
import java.io.IOException;
public class FileRecorder {
private String filename;
public FileRecorder(String filename) {
this.filename = filename;
}
/**
* ファイル保存処理
*/
public void save(String data) {
// ファイルを書き込みオープン
try(FileWriter fw = new FileWriter(filename);
BufferedWriter bw = new BufferedWriter(fw)){
// データを書き込む
bw.write(data);
bw.flush();
} catch (IOException e) {
e.printStackTrace();
}
}
}
package com.example.demo.monolithic;
public class SimpleEncrypter {
/** 暗号化処理 */
public String encrypt(String rawData) {
// 1文字ずつビットを反転させる
StringBuilder encrypted = new StringBuilder();
for(int i = 0; i < rawData.length(); i++)
encrypted.append((char)~(rawData.charAt(i)));
return encrypted.toString();
}
public String decrypt(String encryptedData) {
// 1文字ずつビットを反転させる(encryptedと同じ処理)
return encrypt(encryptedData);
}
}
クラス構成
クラス構成は以下のようになっています。
AccountRecorderクラスはFileRecorderクラスやSimpleEncrypterクラスに依存しています。
アプリケーションによる呼び出し
上記のクラスを利用するアプリケーションは以下のようになります。
package com.example.demo;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import com.example.demo.monolithic.Account;
import com.example.demo.monolithic.AccountRecorder;
@SpringBootApplication
public class Demo1Application {
public static void main(String[] args) {
SpringApplication.run(Demo1Application.class, args);
AccountRecorder accountRecorder = new AccountRecorder("data.txt");
accountRecorder.save(new Account("Taro", "P@ssw0rd"));
}
}
疎結合化する
クラス間の依存度を下げて疎結合とするために、FileRecorderを抽象化したDataRecorderインタフェースを作成し、AccountRecorderはインタフェース型のフィールドを持ちます。
当初の設計ではAccountRecorderのコンストラクタで実装クラスをインスタンス化していましたが、DIによって実装クラスを注入するようにします。
クラス構成
クラス構成は以下のようになります。
Beanを登録する3つの方法
上記のようにインタフェースとクラスを作成するわけですが、Spring FrameworkではDIコンテナにBeanを登録する方法(Configuration)として以下の3つが提供されています。
- JavaベースConfiguration
- XMLベースConfiguration
- アノテーションベースConfiguration
JavaベースConfiguration
1を使用してConfigurationをする場合、AppConfig.javaに@Beanアノテーションを利用して実装クラスのインスタンス化メソッドを記述していきます。
package com.example.demo.di.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import com.example.demo.di.AccountRecorder;
import com.example.demo.di.DataRecorder;
import com.example.demo.di.Encrypter;
import com.example.demo.di.FileRecorder;
import com.example.demo.di.SimpleEncrypter;
@Configuration
public class AppConfig {
@Bean
AccountRecorder accountRecorder() {
return new AccountRecorder();
}
@Bean
DataRecorder dataRecorder() {
return new FileRecorder();
}
@Bean
Encrypter encrypter() {
return new SimpleEncrypter();
}
}
この方法では特に問題がないのですが、Beanの数が増えてくるとこのAppConfig.javaが長大になってしまいますし、一つずつインスタンス化メソッドを作成するのは面倒でもあります。
そこで、前述の3番、アノテーションベースConfigurationを利用すると、インスタンス化メソッドはDIコンテナが自動的に作成してくれます。
例えばAccountRecorderのソースコードは以下のようになります
package com.example.demo.di;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
// ComponentアノテーションをつけることでDIコンテナに登録される
@Component
public class AccountRecorder {
// Autowiredアノテーションをつけることでインスタンスが注入される
// ※DataRecoderの実装クラスに@ComponentアノテーションをつけてDIコンテナに
// 登録しておく
@Autowired
DataRecorder dataRecorder;
@Autowired
Encrypter encrypter;
public AccountRecorder() {}
public void save(Account account) {
// 注入された暗号化Beanで暗号化する
String encrypted = encrypter.encrypt(account.toString());
// 注入されたデータ保存Beanで保存する
dataRecorder.save(encrypted);
}
}
コンポーネントスキャンの設定 ※ここが今回の躓きポイント
@Componentアノテーションを付与したクラスがDIコンテナに登録されるには「コンポーネントスキャン」という仕組みが必要です。
コンポーネントスキャンは、パッケージ階層を再帰的にスキャンして@Componentアノテーションが指定されているクラスを探し、DIコンテナに登録する仕組みとなっており、Java ConfigurationファイルかAnnotationConfigApplicationContextクラスのインスタンス化の際にパッケージ階層の最上位を指定します。
この時、Demo1Application.java(@SpringBootApplicationアノテーションが指定されるクラス)を含めないようにすること が重要です。
コンポーネントスキャンの範囲を以下のように com.example.demo にしてしまうと、
package com.example.demo.di.config;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
@Configuration
@ComponentScan("com.example.demo")
public class AppConfig {
}
javax.management.InstanceAlreadyExsistsExceptionが発生してしまいます。
Exception in thread "main" org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'springApplicationAdminRegistrar' defined in class path resource [org/springframework/boot/autoconfigure/admin/SpringApplicationAdminJmxAutoConfiguration.class]: org.springframework.boot:type=Admin,name=SpringApplication
at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.initializeBean(AbstractAutowireCapableBeanFactory.java:1808)
at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.doCreateBean(AbstractAutowireCapableBeanFactory.java:601)
at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBean(AbstractAutowireCapableBeanFactory.java:523)
at org.springframework.beans.factory.support.AbstractBeanFactory.lambda$doGetBean$0(AbstractBeanFactory.java:336)
at org.springframework.beans.factory.support.DefaultSingletonBeanRegistry.getSingleton(DefaultSingletonBeanRegistry.java:289)
at org.springframework.beans.factory.support.AbstractBeanFactory.doGetBean(AbstractBeanFactory.java:334)
at org.springframework.beans.factory.support.AbstractBeanFactory.getBean(AbstractBeanFactory.java:199)
at org.springframework.beans.factory.support.DefaultListableBeanFactory.instantiateSingleton(DefaultListableBeanFactory.java:1122)
at org.springframework.beans.factory.support.DefaultListableBeanFactory.preInstantiateSingleton(DefaultListableBeanFactory.java:1093)
at org.springframework.beans.factory.support.DefaultListableBeanFactory.preInstantiateSingletons(DefaultListableBeanFactory.java:1030)
at org.springframework.context.support.AbstractApplicationContext.finishBeanFactoryInitialization(AbstractApplicationContext.java:987)
at org.springframework.context.support.AbstractApplicationContext.refresh(AbstractApplicationContext.java:627)
at org.springframework.context.annotation.AnnotationConfigApplicationContext.<init>(AnnotationConfigApplicationContext.java:93)
at com.example.demo.Demo1Application.main(Demo1Application.java:25)
Caused by: javax.management.InstanceAlreadyExistsException: org.springframework.boot:type=Admin,name=SpringApplication
at java.management/com.sun.jmx.mbeanserver.Repository.addMBean(Repository.java:322)
at java.management/com.sun.jmx.interceptor.DefaultMBeanServerInterceptor.registerWithRepository(DefaultMBeanServerInterceptor.java:1848)
at java.management/com.sun.jmx.interceptor.DefaultMBeanServerInterceptor.registerDynamicMBean(DefaultMBeanServerInterceptor.java:945)
at java.management/com.sun.jmx.interceptor.DefaultMBeanServerInterceptor.registerObject(DefaultMBeanServerInterceptor.java:880)
at java.management/com.sun.jmx.interceptor.DefaultMBeanServerInterceptor.registerMBean(DefaultMBeanServerInterceptor.java:315)
at java.management/com.sun.jmx.mbeanserver.JmxMBeanServer.registerMBean(JmxMBeanServer.java:523)
at org.springframework.boot.admin.SpringApplicationAdminMXBeanRegistrar.afterPropertiesSet(SpringApplicationAdminMXBeanRegistrar.java:129)
at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.invokeInitMethods(AbstractAutowireCapableBeanFactory.java:1855)
at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.initializeBean(AbstractAutowireCapableBeanFactory.java:1804)
... 13 more
コンポーネントスキャンの階層は、必ずアプリケーションのJavaクラスのよりも下のパッケージを指定します。
アノテーションベースConfiguration (完成版)
前置きが長くなってしまいましたが、アノテーションベースConfigurationを使用したSpringBootアプリケーションの完成版です。
以下のように、コンポーネントスキャンの基底とするパッケージを作成し、Componentクラスを配置します。
※monolithicは最初の「結合度の高い実装例」が格納されているパッケージですので今回は使用していません。
package com.example.demo.di.config;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
@Configuration
// コンポーネントスキャンの基底パッケージを指定する
@ComponentScan("com.example.demo.di")
public class AppConfig {
// コンポーネントスキャンを使用するためBeanの登録は不要
}
package com.example.demo.di;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
// ComponentアノテーションをつけることでDIコンテナに登録される
@Component
public class AccountRecorder {
// Autowiredアノテーションをつけることでインスタンスが注入される
// ※DataRecoderの実装クラスに@ComponentアノテーションをつけてDIコンテナに
// 登録しておく
@Autowired
DataRecorder dataRecorder;
@Autowired
Encrypter encrypter;
public AccountRecorder() {}
public void save(Account account) {
// 注入された暗号化Beanで暗号化する
String encrypted = encrypter.encrypt(account.toString());
// 注入されたデータ保存Beanで保存する
dataRecorder.save(encrypted);
}
}
package com.example.demo.di;
public class Account {
private String username;
private String password;
public Account(String username, String password) {
super();
this.username = username;
this.password = password;
}
@Override
public String toString() {
StringBuilder sb = new StringBuilder();
sb.append(username);
sb.append(":");
sb.append(password);
return sb.toString();
}
}
package com.example.demo.di;
public interface DataRecorder {
void save(String data);
}
package com.example.demo.di;
public interface Encrypter {
String encrypt(String rawData);
String decrypt(String encryptedData);
}
package com.example.demo.di;
import java.io.BufferedWriter;
import java.io.FileWriter;
import java.io.IOException;
import org.springframework.stereotype.Component;
@Component("dataRecorder")
public class FileRecorder implements DataRecorder{
private static final String filename = "data.txt";
@Override
public void save(String data) {
// ファイルを書き込みオープン
try(FileWriter fw = new FileWriter(filename);
BufferedWriter bw = new BufferedWriter(fw)){
// データを書き込む
bw.write(data);
bw.flush();
} catch (IOException e) {
e.printStackTrace();
}
}
}
package com.example.demo.di;
import org.springframework.stereotype.Component;
@Component("encrypter")
public class SimpleEncrypter implements Encrypter{
@Override
public String encrypt(String rawData) {
// 1文字ずつビットを反転させる
StringBuilder encrypted = new StringBuilder();
for(int i = 0; i < rawData.length(); i++)
encrypted.append((char)~(rawData.charAt(i)));
return encrypted.toString();
}
@Override
public String decrypt(String encryptedData) {
// 1文字ずつビットを反転させる(encryptedと同じ処理)
return encrypt(encryptedData);
}
}
package com.example.demo;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import com.example.demo.di.Account;
import com.example.demo.di.AccountRecorder;
import com.example.demo.di.config.AppConfig;
@SpringBootApplication
public class Demo1Application {
public static void main(String[] args) {
SpringApplication.run(Demo1Application.class, args);
ApplicationContext context =
new AnnotationConfigApplicationContext(AppConfig.class);
// デバッグ用のファイル読み込み処理
// StringBuilder sb = new StringBuilder();
// try(FileReader fr = new FileReader("data.txt");){
// int c = -1;
// do {
// c = fr.read();
// sb.append((char)c);
// }while(c >= 0);
//
// } catch (FileNotFoundException e) {
// e.printStackTrace();
// } catch (IOException e) {
// e.printStackTrace();
// }
AccountRecorder accountRecorder =
(AccountRecorder)context.getBean("accountRecorder");
// ファイルから読み込んだデータを複合化して表示する
// if(sb.length() > 0) {
// Encrypter encrypter = (Encrypter)context.getBean("encrypter");
// String decrypted = encrypter.decrypt(sb.toString());
// System.out.println("Decrypted:" + decrypted);
// }
accountRecorder.save(new Account("Taro", "P@ssw0rd"));
}
}
まとめ
アノテーションベースConfigurationでComponentScanのパッケージ指定を間違えると出てくるExceptionについて、なかなか原因特定に時間がかかってしまったので書き始めたのですが、どちらかというとDIの話がメインになってしまいました。
自分にとっては、基本的なところを振り返る良い機会になりました。
参考になれば幸いです。