Posted at

型安全なプロパティアクセス(1)

More than 1 year has passed since last update.


外部定義された定数

プロパティファイルやらRedisやらMySQLやらに値を突っ込んで、


  • さて、どこに定義してたか?

  • 数値変換ミスったら専用の例外投げなきゃいけない

  • 日時のフォーマット合わせろよ

  • 同じような変換ロジックたくさんできた!

  • プロパティ1つ追加するのにも億劫

みたいな経験、地味にあります。

これを解決する方法を考えてみました。

#本当はデータストアを1つに絞ることがベスト。

検証用のコードはSpringBoot使ってますが、Non-Boot限定の話です。

SpringBoot使ってる場合はSpring Cloud Config使った方が良いです。


うんざりするコード

上記で説明したようなコードを載せてみます。

インターフェースはこんな感じです。

内容は適当です。

LocalDateはjoda-timeのやつです。


ApplicationProperties.java

/** 会員登録可能な最低年齢 */

Integer getJoinableMinAge();
/** 指定したオブジェクにURLが割り当てられていたらURLが返る */
String getUrl(Object object);
/** サービス開始日 */
LocalDate getServiceStartDate();


Redis

$ redis-cli hget "ApplicationProperties" "service.joinable.min.age"

"23"


service.properties

jp.uich.entity.Item.url=/items/{id}

jp.uich.entity.User.url=/users/{name}


MySQL

mysql> select * from application_property;

+--------------------+------------+
| key | value |
+--------------------+------------+
| service.start.date | 2017-02-27 |
+--------------------+------------+

これを普通に実装したらこうなりました。


DefaultApplicationProperties.java

@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);
}
}


長いし見通し悪い。

キーがどこにあるかわからない。。

どのキーがどこに格納されてるの?

そして、こんなロジックはプロパティが増える毎に増えていくのです。

キーを定数にしてクラス上部にまとめたり、同じようなロジックをある程度はメソッドに切り出せるかと思いますが、


  1. インターフェース側にメソッド定義して

  2. 具象クラスでキーを定義して

  3. メソッド実装して

って地味にめんどくさいんです、これが。


理想の形

要は、


  • どこに格納されているかすぐにわかる

  • キーがわかりやすい

  • ごちゃごちゃしてない

を満たしたいのです。

よって、


ApplicationProperties.java

@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を使いましょう。


ApplicationPropertiesProxyFactoryBean.java

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を用意します。


ApplicationPropertiesProxyFactoryBean.java

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です。


ApplicationPropertiesProxyFactoryBean.java

@Override

public void afterPropertiesSet() {
ConversionServiceFactory.registerConverters(Collections.singleton(StringToLocalDateConverter.INSTANCE),
this.conversionService);
}

プロキシ作ります。


ApplicationPropertiesProxyFactoryBean.java

@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使うと便利。

でも、ネストした値のマッピングができないのが、ちょっと痛いところ。

上記のキー文字列の取得箇所↓


ApplicationPropertiesProxyFactoryBean.java

String key = this.getKey(proxy, method, args);


の内容は次の通りです。SpELでも指定可能にしているのでそのあたりの考慮をしておきます。


ApplicationPropertiesProxyFactoryBean.java

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であろうがキーの取得処理は共通なので抽象アノテーションを用意しました。


AbstractStored.java

@Retention(RUNTIME)

@Target(ANNOTATION_TYPE)
public @interface AbstractStored {

@AliasFor("key")
String value() default "";

@AliasFor("value")
String key() default "";
}


このアノテーションを各アノテーションに付与して適切に@AliasForしてやると、取得処理が上記のようにシンプルになります。

例えば@StoredRedis


StoredRedis.java

@Target(ElementType.METHOD)

@Retention(RetentionPolicy.RUNTIME)
@AbstractStored
public @interface StoredRedis {

@AliasFor(annotation = AbstractStored.class)
String value() default "";

@AliasFor(annotation = AbstractStored.class)
String key() default "";
}


この↓箇所については割愛します。ただ単にmethodargから変数を登録しているだけです。


ApplicationPropertiesProxyFactoryBean.java

StandardEvaluationContext evalContext = this.createEvalContext(proxy, method, args);


次は値取得の箇所


ApplicationPropertiesProxyFactoryBean.java

String valueAsText = this.getPureValue(key, method);


の呼出先です。


ApplicationPropertiesProxyFactoryBean.java

@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 + "]]");
}


これで準備完了。


検証

いつものようにコントローラ用意して実行してみる。

コントローラはこんな感じです。


DemoController.java

@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
]


まとめ



  • :o:実際に運用してみたがインターフェースメソッドの追加だけっていうのは本当に楽。


  • :o:戻りの型のパターンが増えてもConversionServiceConverter追加するだけ。


  • :o:プロパティの見通しが良くなった


  • :x:そもそも定義先がバラけてるの何とかした方が良い


  • :x:RedisやMySQLにデータ入れ忘れる(※1)


  • :x:アクセスが多いプロパティだとRedisやMySQLへのアクセス負荷が高い(※2)

次回は※1と※2について何とかしたいと思います。

今回のコードです。