外部定義された定数
プロパティファイルやらRedisやらMySQLやらに値を突っ込んで、
- さて、どこに定義してたか?
- 数値変換ミスったら専用の例外投げなきゃいけない
- 日時のフォーマット合わせろよ
- 同じような変換ロジックたくさんできた!
- プロパティ1つ追加するのにも億劫
みたいな経験、地味にあります。
これを解決する方法を考えてみました。
#本当はデータストアを1つに絞ることがベスト。
検証用のコードはSpringBoot使ってますが、Non-Boot限定の話です。
SpringBoot使ってる場合はSpring Cloud Config使った方が良いです。
うんざりするコード
上記で説明したようなコードを載せてみます。
インターフェースはこんな感じです。
内容は適当です。
LocalDate
はjoda-timeのやつです。
/** 会員登録可能な最低年齢 */
Integer getJoinableMinAge();
/** 指定したオブジェクにURLが割り当てられていたらURLが返る */
String getUrl(Object object);
/** サービス開始日 */
LocalDate getServiceStartDate();
$ redis-cli hget "ApplicationProperties" "service.joinable.min.age"
"23"
jp.uich.entity.Item.url=/items/{id}
jp.uich.entity.User.url=/users/{name}
mysql> select * from application_property;
+--------------------+------------+
| key | value |
+--------------------+------------+
| service.start.date | 2017-02-27 |
+--------------------+------------+
これを普通に実装したらこうなりました。
@Override
public Integer getJoinableMinAge() {
String value = this.hashOps().get(ApplicationProperties.class.getSimpleName(), "service.joinable.min.age");
if (value == null) {
throw new ApplicationPropertyException("service.joinable.min.age", "定義されていません。");
}
try {
return Integer.valueOf(value);
} catch (NumberFormatException e) {
throw new ApplicationPropertyException("service.joinable.min.age", "数値ではありません。", e);
}
}
@Override
public String getUrl(Object object) {
String urlFormat = (String) this.properties.get(object.getClass().getName() + ".url");
if (StringUtils.isBlank(urlFormat)) {
throw new ApplicationPropertyException(object.getClass().getName() + ".url", "定義されていません。");
}
try {
Map<String, Object> placeholder = this.objectMapper.convertValue(object,
new TypeReference<Map<String, Object>>() {});
return StrSubstitutor.replace(urlFormat, placeholder, "{", "}");
} catch (Exception e) {
throw new ApplicationPropertyException(object.getClass().getName() + ".url", "URL文字列生成時にエラーが発生しました。", e);
}
}
@Override
@Transactional
public LocalDate getServiceStartDate() {
ApplicationProperty property = this.repository.getByKey("service.start.date");
if (property == null || StringUtils.isBlank(property.getValue())) {
throw new ApplicationPropertyException("service.start.date", "定義されていません。");
}
try {
return LocalDate.parse(property.getValue(), DateTimeFormat.forPattern("yyyy-MM-dd"));
} catch (IllegalArgumentException e) {
throw new ApplicationPropertyException("service.start.date", "日付型のパースに失敗しました。", e);
}
}
長いし見通し悪い。
キーがどこにあるかわからない。。
どのキーがどこに格納されてるの?
そして、こんなロジックはプロパティが増える毎に増えていくのです。
キーを定数にしてクラス上部にまとめたり、同じようなロジックをある程度はメソッドに切り出せるかと思いますが、
- インターフェース側にメソッド定義して
- 具象クラスでキーを定義して
- メソッド実装して
って地味にめんどくさいんです、これが。
理想の形
要は、
- どこに格納されているかすぐにわかる
- キーがわかりやすい
- ごちゃごちゃしてない
を満たしたいのです。
よって、
@StoredRedis("service.joinable.min.age")
Integer getJoinableMinAge();
@StoredFile("#object.class.name + '.url'")
String getUrl(Object object);
@StoredDB("service.start.date")
LocalDate getServiceStartDate();
こんな形が理想です。
これ、インターフェース側です。
そして、実装クラスは無し!!
追加する時は、インターフェースにメソッド追加して終わり!
Springっぽい。
まぁ、何もしなければそんなことは実現できないので、実現方法を考えてみます。
Proxyを実装する
Springも採っている手法ですが、こんな時はProxyですね。
なので、Proxyを実装してみます。
DI登録がめんどくさいのでFactoryBean
を使いましょう。
public class ApplicationPropertiesProxyFactoryBean implements FactoryBean<ApplicationProperties> , InitializingBean {
/** メソッド引数の名前を解決する */
private static final ParameterNameDiscoverer PARAM_NAME_DISCOVERER = new DefaultParameterNameDiscoverer();
/** Proxyオブジェクト */
@Getter
private ApplicationProperties object;
/** プロパティの文字列からメソッドの戻りの型に変換するためのサービス */
private FormattingConversionService conversionService = new DefaultFormattingConversionService();
/** キー文字列で指定され得るSpEL式をパースする */
private ExpressionParser elParser = new SpelExpressionParser();
/** ファイルに定義されたプロパティ全部 */
@Resource(name = "service.properties")
private Properties properties;
/** Redisに突っ込んだプロパティにアクセスする */
@Autowired
private StringRedisTemplate redisOps;
/** DBに突っ込んだプロパティにアクセスする */
@Autowired
private ApplicationPropertiesRepository repository;
/** プロパティ値に埋め込まれたプレースホルダを生成する */
private ObjectMapper objectMapper = new ObjectMapper();
private HashOperations<String, String, String> hashOps() {
return this.redisOps.opsForHash();
}
// 実装に続く
}
こんな感じで必要なコンポーネントを用意します。
LocalDate
用にConverter
を用意します。
private enum StringToLocalDateConverter implements Converter<String, LocalDate> {
INSTANCE;
@Override
public LocalDate convert(String source) {
return LocalDate.parse(source, DateTimeFormat.forPattern("yyyy-MM-dd"));
}
}
ConversionService
に追加します。
その他の変換ルールはデフォルトで設定されるConverter
でOKです。
@Override
public void afterPropertiesSet() {
ConversionServiceFactory.registerConverters(Collections.singleton(StringToLocalDateConverter.INSTANCE),
this.conversionService);
}
プロキシ作ります。
@Override
public void afterPropertiesSet() {
// … for conversionService.
this.object = (ApplicationProperties) Proxy.newProxyInstance(ClassUtils.getDefaultClassLoader(),
new Class[] { ApplicationProperties.class }, (proxy, method, args) -> {
// equals&toSring&hashCodeの実装
if (ReflectionUtils.isEqualsMethod(method)) {
return EqualsBuilder.reflectionEquals(this.object, args[0]);
}
if (ReflectionUtils.isToStringMethod(method)) {
return ToStringBuilder.reflectionToString(this.object);
}
if (ReflectionUtils.isHashCodeMethod(method)) {
return HashCodeBuilder.reflectionHashCode(this.object);
}
// キー文字列を取得する
String key = this.getKey(proxy, method, args);
// 純粋に定義されている値をそのまま文字列型で取得する
String valueAsText = this.getPureValue(key, method);
// 今回は検証用なので引数一つの場合のみ対応
if (args != null && args.length == 1) {
try {
// 指定された引数からプレースホルダ生成
Map<String, Object> placeholder = this.objectMapper.convertValue(args[0],
new TypeReference<Map<String, Object>>() {});
// プロパティ値のプレースホルダに値を埋め込む
valueAsText = StrSubstitutor.replace(valueAsText, placeholder, "{", "}");
} catch (Exception e) {
throw new ApplicationPropertyException(key, "プレースホルダバインド時にエラーが発生しました。]", e);
}
}
// メソッドの戻りの型
TypeDescriptor toType = new TypeDescriptor(MethodParameter.forMethodOrConstructor(method, -1));
try {
// メソッドの戻りの型に変換
return this.conversionService.convert(valueAsText, toType);
} catch (Exception e) {
throw new ApplicationPropertyException(key, "戻り値の変換処理に失敗しました。", e);
}
});
}
プレースホルダの生成にObjectMapper
使うと便利。
でも、ネストした値のマッピングができないのが、ちょっと痛いところ。
上記のキー文字列の取得箇所↓
String key = this.getKey(proxy, method, args);
の内容は次の通りです。SpELでも指定可能にしているのでそのあたりの考慮をしておきます。
private String getKey(Object proxy, Method method, Object[] args) {
StandardEvaluationContext evalContext = this.createEvalContext(proxy, method, args);
AbstractStored stored = AnnotatedElementUtils.findMergedAnnotation(method, AbstractStored.class);
String key = stored.key();
Assert.isTrue(StringUtils.isNotBlank(key), "キーが空文字列です。 [method:[" + method + "]]");
try {
return this.elParser.parseExpression(key).getValue(evalContext, String.class);
} catch (Exception e) {
return key;
}
}
@AbstractStored
アノテーションは@StoredRedis
であろうが@StoredFile
であろうが@StoredDB
であろうがキーの取得処理は共通なので抽象アノテーションを用意しました。
@Retention(RUNTIME)
@Target(ANNOTATION_TYPE)
public @interface AbstractStored {
@AliasFor("key")
String value() default "";
@AliasFor("value")
String key() default "";
}
このアノテーションを各アノテーションに付与して適切に@AliasFor
してやると、取得処理が上記のようにシンプルになります。
例えば@StoredRedis
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@AbstractStored
public @interface StoredRedis {
@AliasFor(annotation = AbstractStored.class)
String value() default "";
@AliasFor(annotation = AbstractStored.class)
String key() default "";
}
この↓箇所については割愛します。ただ単にmethod
とarg
から変数を登録しているだけです。
StandardEvaluationContext evalContext = this.createEvalContext(proxy, method, args);
次は値取得の箇所
String valueAsText = this.getPureValue(key, method);
の呼出先です。
@NonNull
private String getPureValue(String key, Method method) {
// DBに格納されている値を取得する
StoredDB storedDB = AnnotatedElementUtils.findMergedAnnotation(method, StoredDB.class);
if (storedDB != null) {
Assert.isTrue(StringUtils.isNotBlank(key), "キーが空文字列です。 [method:[" + method + "]]");
return Optional.ofNullable(this.repository.getByKey(key))
.map(ApplicationProperty::getValue)
.filter(StringUtils::isNotBlank)
.orElseThrow(() -> new ApplicationPropertyException(storedDB.value(), "定義されていません。"));
}
// Redisに格納されている値を取得する
StoredRedis storedRedis = AnnotatedElementUtils.findMergedAnnotation(method, StoredRedis.class);
if (storedRedis != null) {
Assert.isTrue(StringUtils.isNotBlank(key), "キーが空文字列です。 [method:[" + method + "]]");
return Optional.ofNullable(this.hashOps().get(ApplicationProperties.class.getSimpleName(), key))
.filter(StringUtils::isNotBlank)
.orElseThrow(() -> new ApplicationPropertyException(key, "定義されていません。"));
}
// プロパティファイルに格納されている値を取得する
StoredFile storedFile = AnnotatedElementUtils.findMergedAnnotation(method, StoredFile.class);
if (storedFile != null) {
Assert.isTrue(StringUtils.isNotBlank(key), "キーが空文字列です。 [method:[" + method + "]]");
return Optional.ofNullable((String) this.properties.get(key))
.filter(StringUtils::isNotBlank)
.orElseThrow(() -> new ApplicationPropertyException(key, "定義されていません。"));
}
throw new IllegalStateException("データの定義先が指定されていません。 [method:[" + method + "]]");
}
これで準備完了。
検証
いつものようにコントローラ用意して実行してみる。
コントローラはこんな感じです。
@Autowired
private ApplicationProperties properties;
@GetMapping("/age")
public Integer age() {
return this.properties.getJoinableMinAge();
}
@GetMapping("/items/{id}/url")
public String itemUrl(@PathVariable Long id) {
return this.properties.getUrl(Item.builder().id(id).build());
}
@GetMapping("/users/{id}/url")
public String userUrl(@PathVariable Long id) {
return this.properties.getUrl(User.builder().id(id).name("kenny").build());
}
@GetMapping("/date")
public LocalDate date() {
return this.properties.getServiceStartDate();
}
いざ。
$ curl http://localhost:8080/age && echo
23
$ curl http://localhost:8080/items/2/url && echo
/items/2
$ curl http://localhost:8080/users/10/url && echo
/users/kenny
$ curl http://localhost:8080/date --silent | jq ".values"
[
2017,
2,
27
]
まとめ
- 実際に運用してみたがインターフェースメソッドの追加だけっていうのは本当に楽。
-
戻りの型のパターンが増えても
ConversionService
にConverter
追加するだけ。 - プロパティの見通しが良くなった
- そもそも定義先がバラけてるの何とかした方が良い
- RedisやMySQLにデータ入れ忘れる(※1)
- アクセスが多いプロパティだとRedisやMySQLへのアクセス負荷が高い(※2)
次回は※1と※2について何とかしたいと思います。
今回のコードです。