MicroAd Developers Blog

マイクロアドのエンジニアブログです。インフラ、開発、分析について発信していきます。

「メソッドの引数名 -> メソッド引数」のMapを生成しようとした話

f:id:microad-developer:20190520183712p:plain

去年の10月1日から新卒エンジニアとしてマイクロアドで働いている宮田です。
今回は、現在開発中のプロダクトのサーバーサイドで、イケてない部分を改善しようとして失敗した話を書きます。

困っていたこと

開発中のプロダクトのDAOでは、SpringBootのNamedParameterJdbcTemplateを用いてデータベースを操作します。
この操作には、操作用のクエリと、クエリのプリペアドステートメントに対応するパラメータマップの2つが必要です。

実際に操作する様子のサンプルコードは以下の通り1です。
自分はこのコードを見て、「一々マップ用の文字列を書く必要があるとか、本質的じゃないコードが多いのが気持ち悪い」と思いました。

List<Object> hoge(Integer huga, String piyo, ...) {
  Map<String, Object> map = new HashMap<>();
  map.put("huga", huga);
  map.put("piyo", piyo);
    
  // 省略

  // クエリ実行
  return namedParameterJdbcTemplate.query(/* クエリ文字列 */, map, /* マッパー */);
}

考えた解決方法

「以下の手順で処理を行えばイケてる感じになるのでは?」と考えました。

  1. 生成関数で、リフレクションを用いて呼び出し元メソッドの引数名を取得
  2. 取得した引数名と与えられた引数からマップを生成して返す

リフレクションを使ってゴニョゴニョするなど黒魔術感がありますが、利用する上ではシンプルになるので良さげだと思いました。

実装してみた

詰める前に諦めたので雑実装です。

使い方

実装の前に使い方を説明します。 以下の手順でMapの生成が行えます。

  1. アノテーションを付与する(マップしたくない引数名は指定する)
  2. メソッドへ入力されるのと同じ順でMap生成関数にパラメータを渡す

以下サンプルコードです。
引数を渡せばマップが生成されるので、コード量が減って本質的な処理の割合が多くなっていることが分かります。
その他必要なパラメータについても後から渡すことができます。

// ParamStylesアノテーションを付与、引数名mogeは無視する
@ParameterMapUtil.ParamStyles(ignoreParameters = {"moge"})
List<Object> hoge(Integer huga, String piyo, Double moge ...) {
  Map<String, Object> map = generateParameterMap(huga, piyo, ...);

  // クエリ実行
  return namedParameterJdbcTemplate.query(/* クエリ文字列 */, map, /* Rowマッパー */);
}

ParamStylesに関する実装全体

前節で付与していたParamStylesアノテーションと、それに関する処理の全体は以下の通りです。

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import java.lang.reflect.Method;
import java.lang.reflect.Parameter;
import java.util.Arrays;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;

public class ParameterMapUtil {
  @Target(ElementType.METHOD)
  @Retention(RetentionPolicy.RUNTIME)
  public @interface ParamStyles {
    /**
     * @return 処理時にignoreするパラメータ名の配列
     */
    String[] ignoreParameters() default {};
  }

  /**
   * @return StackTraceElementに対応する呼び出し元メソッド
   */
  private static Method getCallerMethod(StackTraceElement caller) {
    try {
      // TODO: 同名メソッドが複数あった場合を作っていない
      return Arrays.stream(Class.forName(caller.getClassName()).getDeclaredMethods())
          .filter(it -> it.getName().equals(caller.getMethodName()))
          .findFirst()
          .orElseThrow(() -> new RuntimeException("メソッドが見つかりませんでした。"));
    } catch (ClassNotFoundException e) {
      throw new RuntimeException("リフレクションでエラーがありました。", e);
    }
  }

  /**
   * @return メソッド引数に対応するパラメーターのマップ
   */
  public static Map<String, Object> generateParameterMap(Object... parameters) {
    // 呼び出し元を取得
    Method callerMethod = getCallerMethod(new Throwable().getStackTrace()[1]);

    // パラメーター一覧を取得
    ParamStyles styles =  Optional.ofNullable(callerMethod.getAnnotation(ParamStyles.class))
        .orElseThrow(() -> new RuntimeException("ParamStylesアノテーションが付与されていません。"));
    Parameter[] methodParameters = Arrays.stream(callerMethod.getParameters())
        .filter(it -> !Arrays.asList(styles.ignoreParameters()).contains(it.getName()))
        .toArray(Parameter[]::new);

    // 引数をチェック
    if (methodParameters.length != parameters.length) {
      throw new RuntimeException("パラメータ数と渡された引数の数が異なります。");
    }

    // マップの作成
    Map<String, Object> parameterMap = new HashMap<>();
    for (int i = 0; i < parameters.length; i++) {
      parameterMap.put(methodParameters[i].getName(), parameters[i]);
    }
        
    return parameterMap;
  }
}

実装要素

個々の要素を説明します。

付与するアノテーション

そのままマップしてしまうと関係ないパラメータまでマップしてしまうので、引数名を指定してignoreできるように配列を持たせています。

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface ParamStyles {
  /**
   * @return 処理時にignoreするパラメータ名の配列
   */
  String[] ignoreParameters() default {};
}

呼び出し元メソッドの取得

呼び出し元メソッドはスタックトレースから以下のように取得できます。 以下3点、注釈です。

  • 便宜上try-catchしていますが、取得できない可能性は多分ありません
  • 複数一致が得られた場合の処理は作っていません
  • getStackTrace()[1]2を指定することで呼び出し元を特定できます
/**
 * @return StackTraceElementに対応する呼び出し元メソッド
 */
private static Method getCallerMethod(StackTraceElement caller) {
  try {
    // TODO: 同名メソッドが複数あった場合を作っていない
    return Arrays.stream(Class.forName(caller.getClassName()).getDeclaredMethods())
        .filter(it -> it.getName().equals(caller.getMethodName()))
        .findFirst()
        .orElseThrow(() -> new RuntimeException("メソッドが見つかりませんでした。"));
  } catch (ClassNotFoundException e) {
    throw new RuntimeException("リフレクションでエラーがありました。", e);
  }
}

// 呼び出し方
Method callerMethod = getCallerMethod(new Throwable().getStackTrace()[1]);

アノテーションとパラメータ一覧の取得

以下のように取得できます。
細々した部分はコメントを参照してください。

// アノテーション取得
ParamStyles styles =  Optional.ofNullable(callerMethod.getAnnotation(ParamStyles.class))
    .orElseThrow(() -> new RuntimeException("ParamStylesアノテーションが付与されていません。"));
// メソッドの引数一覧から、ignoreする引数を除いた一覧を取得
Parameter[] methodParameters = Arrays.stream(callerMethod.getParameters())
    .filter(it -> !Arrays.asList(styles.ignoreParameters()).contains(it.getName()))
    .toArray(Parameter[]::new);

実装した結果

以下、「リフレクション!Stream!コード量削減!イケてる!」という気分で実装を終えたのち、先輩に指摘を頂いた点と自分で反省した課題点です。

  • メソッド引数への依存が強すぎ、「DAOで引数を受け取って処理した結果からマップする」ような場合に対応しにくい
  • 使い方を間違っても一切警告が出ないため、深いバグになりやすい
  • 現状では大量の変数をマップするような機会は少なく、そこまで困っていない(=保守の手間の方が大きい)

実装には二時間弱掛けましたが、このコードは不採用になってしまいました……。

反省と改善

実装している間はものすごくいい気分で書けていましたが、どれだけ時間をかけ工夫を凝らしたコードであっても、製品に組み込むからにはメリットが多くなければなりません。
特に凝ったユーティリティを作るのはメリットデメリットをちゃんと整理する必要があって、その辺りをちゃんと考えていないと結果は出ないですね……。

まとめ

今回の教訓は以下の通りです。

  • 実装前に需要やメリットを確認すること
  • 実装中に「これ本当に使いやすいか?安全か?」と確認すること
  • 実装が採用されなくてもめげないこと!

  1. 実際のプロダクトではMapではなくSqlParameterSourceを用いていますが、ここでは簡単のためにMapでサンプルを書いています。

  2. getStackTrace()[0]にはgetStackTrace()を呼び出した関数が入ります。