Jackson に独自アノテーションへの処理を追加する

Jackson でシリアライズしてログに残すときに、認証トークンみたいなセンシティブなデータは潰したいし、それをアノテーションでお手軽に指定したいって思ったので調べた。

* [とりあえず動くソースコード見たい人向け][1]

やりたいこと
--------------

こんな感じで POOJO のフィールドに `@Sensitive` って付けたプロパティは適当な文字列に置換して欲しい:

~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~java
@lombok.Value
public class SomeRequest {
    /** 隠す必要無いプロパティ */
    private final String userId;

    /** センシティブな情報 */
    @Sensitive
    private final String token;
}
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~


やり方
----------

置換処理の実装と、その実装が働くアノテーションへの関連付けと、それらを ObjectMapper に登録するモジュールの3つのクラスが必要。

アノテーションがついたフィールドに対する処理をするクラス:

~~~~~~~~~~~~~~~~~~~~~~~~~~~~~SensitiveFieldMaskingSerializer.java
public class SensitiveFieldMaskingSerializer extends StdSerializer<Object> {
    private static final long serialVersionUID = 3888199957574169748L;

    protected SensitiveFieldMaskingSerializer() {
        super(Object.class);
    }

    @Override
    public void serialize(Object value, JsonGenerator gen, SerializerProvider provider) throws IOException {
        // 適当に置換する処理
        if (value instanceof Number) {
            gen.writeNumber(0);
        } else if (value instanceof String) {
            gen.writeString("Hidden: Sensitive string");
        } else {
            gen.writeNull();
        }
    }
}
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

↑の実装が起動するアノテーションを関連付けるクラス:

~~~~~~~~~~~~~~~~~~~~~~~~~~~~~SensitiveFieldMaskingAnnotationIntrospector.java
public class SensitiveFieldMaskingAnnotationIntrospector extends NopAnnotationIntrospector {
    private static final long serialVersionUID = -4171975975956047379L;

    @Override
    public Object findSerializer(Annotated am) {
        if (am.hasAnnotation(Sensitive.class)) {
            return SensitiveFieldMaskingSerializer.class;
        }
        return null;
    }
}
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

↑を ObjectMapper に登録する処理クラス:

~~~~~~~~~~~~~~~~~~~~~~~~~~~~SensitiveFieldMaskingModule.java
static class SensitiveFieldMaskingModule extends Module {
    @Override
    public String getModuleName() {
        return getClass().getSimpleName();
    }

    @Override
    public Version version() {
        return Version.unknownVersion();
    }

    @Override
    public void setupModule(SetupContext context) {
        context.insertAnnotationIntrospector(new SensitiveFieldMaskingAnnotationIntrospector());
    }
}
~~~~~~~~~~~~~~~~~~~~~~~~~~~~

これで必要な実装のすべてなので使ってみましょう:

~~~~~~~~~~~~~~~~~~~~~~~~~~~~java
public final class Main {
    public static void main(String[] args) throws Exception {
        ObjectMapper logObjectMapper = new ObjectMapper()
                .registerModule(new SensitiveFieldMaskingModule());

        FooRequest fooRequest = new FooRequest("ログに残していいやつ", "ログに残すのダメなやつ");

        System.out.println("シリアライズ結果: " + logObjectMapper.writeValueAsString(fooRequest));
        // シリアライズ結果: {"userId":"ログに残していいやつ","token":"Hidden: Sensitive string"}

        System.out.println("Map へ変換結果:  " + logObjectMapper.convertValue(fooRequest, Map.class));
        // Map へ変換結果:  {userId=ログに残していいやつ, token=Hidden: Sensitive string}
    }
}
~~~~~~~~~~~~~~~~~~~~~~~~~~~~

両出力とも `token` が別の文字列に置換されているのがわかる。

おわり
------------

上にもリンク張ったけど、一通り動くやつを gist に置いた。

* [一通り動くやつ][1]

余談だけど、今回のように通常用途とは違う ObjectMapper が必要な時は、他所で使ってる ObjectMapper を使いまわさず、新たにログを残すためのものとして用意したほうが無用なトラブルを避けられる。DI コンテナを使っている場合は面倒くさがらず名前付き Bean として定義しよう。

[1]: https://gist.github.com/kui/c3c5bc4ce76fc092f0849187cea6b75a