Spring BootのRedisCacheManagerでJSONをいろんな型で利用する

Spring Bootのキャッシュ抽象化(Redis)でJSONを利用したい場合のメモです。

■ 目次

そもそもなんでJSON?

Redisの中身を直接調べる場合に、ヒューマンリーダブルであることが大きな理由です。デフォルトであるJavaのオブジェクトシリアライズ文字列だと、ほとんど中身が分からないので。

Configuration

先に結論ですが、Configurationは以下のようになりました。

やっていることは、

  • デフォルトとJSON用の2つの CacheManager を登録 *1
  • シリアライズでエラーがあった場合、無視して直接メソッドが呼ばれるように、カスタムのエラーハンドラを登録

の2つです。

@Configuration
@EnableCaching
public class CacheConfig extends CachingConfigurerSupport {

    /**
     * デフォルトのCacheManager
     */
    @Bean
    @Primary
    CacheManager cacheManager(RedisTemplate<String, Object> redisTemplate) {
        Map<String, Long> expires = new HashMap<>();
        // 略: キャッシュの有効期間を設定
    
        RedisCacheManager cacheManager = new RedisCacheManager(redisTemplate);
        cacheManager.setExpires(expires);

        return cacheManager;
    }

    /**
     * JSON版のCacheManager
     */
    @Bean(name = "JsonCacheManager")
    CacheManager jsonCacheManager(RedisConnectionFactory connectionFactory) {
        ObjectMapper mapper = new ObjectMapper()
                .registerModule(new JavaTimeModule()) // LocalDateTime などの Date and Time API 関連のフィールドを扱う 
                .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) // 不明なプロパティがあっても無視
                .enableDefaultTypingAsProperty(ObjectMapper.DefaultTyping.JAVA_LANG_OBJECT, "@class"); // デコード時の型の解決(後述)

        StringRedisTemplate redisTemplate = new StringRedisTemplate();
        redisTemplate.setKeySerializer(new StringRedisSerializer()); // キーはシリアライズせずにただの文字列にする
        redisTemplate.setValueSerializer(new GenericJackson2JsonRedisSerializer(mapper)); // 値はJSONエンコード・デコードを利用する
        redisTemplate.setConnectionFactory(connectionFactory);
        redisTemplate.afterPropertiesSet();

        Map<String, Long> expires = new HashMap<>();
        // 略: キャッシュの有効期間を設定

        RedisCacheManager cacheManager = new RedisCacheManager(redisTemplate);
        cacheManager.setExpires(expires);
        return cacheManager;
    }

    @Override
    @Bean
    public CacheErrorHandler errorHandler() {

        return new SimpleCacheErrorHandler() {

            @Override
            public void handleCacheGetError(RuntimeException exception, Cache cache, Object key) {
                // キャッシュからのデシリアライズに失敗した場合は、エラーにはせずに対象のメソッドをそのまま呼ぶ(その結果はキャッシュされる)
                if (isSerializationError(exception)) {
                    return;
                }

                throw exception;
             }

            private boolean isSerializationError(RuntimeException exception) {
                if (exception instanceof SerializationException) {
                    return true;
                }

                Throwable cause = exception.getCause();
                return (cause != null && cause instanceof SerializationException);
            }
        }
    }
}

Jackson2JsonRedisSerializerとGenericJackson2JsonRedisSerializerの違いについて

CacheConfig の中では、 value serializer(Redisへの値の書き込み・読み込み手段)として GenericJackson2JsonRedisSerializer を使いました。

そもそもRedisTemplate に渡すJSON 用の value serializer には2種類あります。

  • Jackson2JsonRedisSerializer
  • GenericJackson2JsonRedisSerializer

両者の違いは、シリアライズ・デシリアライズの対象の型にあります。前者は特定の型に対してのものであり、後者はいろんな型( java.lang.Object とそのサブクラス)で使えます。
インスタンス作成方法の違いを見ると分かりやすいかもしれません。

Jackson2JsonRedisSerializer<Entity> x = new Jackson2JsonRedisSerializer<>(Entity.class);
x.setObjectMapper(mapper);

GenericJackson2JsonRedisSerializer y = new GenericJackson2JsonRedisSerializer(mapper);

Jackson2JsonRedisSerializer はコンストラクタ中でクラスを指定する必要があることから、結果的に、キャッシュが返す型ごとに CacheManager を用意する必要があり、煩雑になります。JSON用の CacheManager は1つにしたいので、 GenericJackson2JsonRedisSerializer を使っています。

ちなみに、

Jackson2JsonRedisSerializer<Object> x = new Jackson2JsonRedisSerializer<>(Object.class);

とすればいいじゃん?と思うかもしれませんが、これは、結局 GenericJackson2JsonRedisSerializer を使った場合と同じ挙動になります。

GenericJackson2JsonRedisSerializerを使うと、戻り値がLinkedHashMapになり、呼び出し時にキャストエラーになる

さてここで一つ問題があります。単純にGenericJackson2JsonRedisSerializerを使っただけだと、キャッシュから値が取得できた場合に戻り値が LinkedhashMap になってしまいます。
そうすると、メソッドを呼び出して結果を代入する箇所で ClassCastException(実行時エラー)が起きてしまいます。

具体的な例を示します。キャッシュを使ったメソッドの実装を以下のようにした場合 *2 に、

@Service
@RequiredArgsConstructor // Lombok
@Transactional(readOnly = true)
public class HogeService {

    private final HogeRepository hogeRepository;

    @Cacheable(value = "aaa", key = "'example:hoge:' + #key", cacheManager = "JsonCacheManager")
    public Hoge findByKey(String key) {
        return hogeRepository.findByKey(key);
    }
}

呼び出し側は

Hoge hoge = hogeService.findByKey("xxx");

となりますが、ここで、 LinkedHashMap から Hoge へのキャストが発生し、 ClassCastException が起きます。

キャッシュ内のJSON中に、型解決のためのヒントとなるプロパティを埋め込むようにする

上記の問題点の解決方法です。

まず、Spring BootというよりJacksonの話になりますが、デコード時の型として java.lang.Object を指定した場合、戻り値の型は、JSON文字列に応じて以下のようになります。

  • “1” とか “5.3” などの数値の場合 => IntegerDouble
  • “[ 3, 4 ]” などのリストの場合 => ArrayList
  • “{ "aaa” : “nnn” }“ のようなオブジェクトの場合 => LinkedHashMap

GenericJackson2JsonRedisSerializer の実装では、java.lang.Object を使ってデコードをしようとしていることから、キャッシュから値が取得できた時に LinkedHashMap になってしまい、戻り値の型 Hoge へのキャストが発生しているわけですね。

java.lang.Object ではなく、戻り値の型を使ってデコードしてくれればいいのに…」と思うわけですが、残念ながら RedisSerializer のデシリアライズのメソッドシグネチャは、

    T deserialize(byte[] bytes) throws SerializationException;

のようになっており、メソッドの引数から、戻り値の型を解決することができません。

じゃあどうすればよいか?と思うわけですが deserialize メソッドの引数はデコード対象のバイト列(JSON文字列)だけです。 なので、これを唯一の手がかりとして、JSON文字列の中に、具体的な型を解決するためのヒントを埋め込んであげればよさそうです。

キャッシュから値を読み込む時に、型情報を参照にする

CacheConfig 中の以下のコードが該当します。

objectMapper.enableDefaultTypingAsProperty(ObjectMapper.DefaultTyping.JAVA_LANG_OBJECT, "@class"); 

これは「デコード時に指定された型が java.lang.Object の場合、JSON文字列中の “@class” プロパティをヒントにして型を解決してね」という意味です。なお、 “@class” の値には、クラスのFQCN文字列がセットされます。

ちなみに、JSON文字列中に “@class” プロパティが存在しない場合はエラーとなります。キャッシュの中身が不正なだけで、呼び出すたびにエラーになってしまうのは不便なので、カスタムエラーハンドラを作り、 SerializationException の時は無視してメソッドの呼び出しを行なうようにしています。

キャッシュに値が書き込む時に、型情報を付与する

クラスに @JsonType アノテーションを付けてあげます。

package sample;

@JsonTypeInfo(use = JsonTypeInfo.Id.CLASS, include = JsonTypeInfo.As.PROPERTY, property = "@class")
public class Hoge {
    // 中身は省略
}

これにより、JSONエンコード時、すなわちキャッシュに値を書き込む時に、 @class というプロパティに “sample.Hoge” が付与されて書き込まれます。

まとめ

Spring BootのRedisCacheManagerでJSONをいろんな型で利用する場合のまとめです。

  • CacheManager を用意し、 value serializer に GenericJackson2JsonRedisSerializer を与える
  • キャッシュの戻り値として利用するクラスには @JsonTypeInfo を付けてあげる
  • GenericJackson2JsonRedisSerializer にセットする ObjectMapper には、型を解決するためのヒントを enableDefaultTypingAsPropertysetDefaultTyping メソッドで指定してあげる
  • キャッシュのデシリアライズに失敗した場合に備えて、 `CachingConfigurerSupport#errorHandler をオーバーライドしてカスタムのエラーハンドラを作り、デシリアライズエラー時はキャッシュを諦めてメソッドを呼ぶようにした方が、(おそらく)安全

*1:複数の CacheManager を統合する CompositeCacheManager というのもありますが、今回は簡略化のため複数利用しました。

*2:キャッシュの使い方としてちょっと古いバージョンの実装かもしれません。。

エラー処理を考慮したコールアウト機構としてプラットフォームイベントが使えないか? #salesforce

昨日の続き。

jappy.hatenablog.com

Salesforce to 外部ソース」のデータ連携(HTTPコールアウト)時に、プラットフォームイベントが有効に使えないか?と、ふと考えました。

この手の話では、これまでは @futureQueueable Apexを使うのが常套手段だったかと思います。ただ、コールアウトが絡んだアーキテクチャ設計においては、単発でコールできればそれでOKというわけではなく、送信失敗時のエラーハンドリングやリトライについても考える必要があります。

例えば以下の記事です。

developer.salesforce.com

上記の記事の趣旨は「 @future よりも Queueable を使おう」というものですが、コールアウト処理のステータスを保持するカスタムオブジェクト ( Callout__c )を作って、実行履歴を残したり後々のリカバリができるような余地を残している、というのが一つ注目すべきポイントです。

これに近いことがプラットフォームイベントでもできるんじゃないかなーと思ってます。
保持期間が24時間なようなので、履歴の保持はできないかもしれませんが。

Summer '17で正式リリースされたプラットフォームイベントの有効な使い方を考えてみた #salesforce

Salesforce Summer ‘17でプラットフォームイベント(エンタープライズメッセージングプラットフォーム)なる機能が正式リリース されました。

新機能については、以下の動画で分かりやすく説明されてます。

Summer ‘17 開発者向け新機能 - YouTube

動画を見ると、このプラットフォームイベントという機能、なんとなく便利そうだなーという気がしてきます。しかし、返品リクエストのデモ(00:50あたり)だけだと、その便利さについてまだ腹落ちできませんでした*1

  • 今まででも同じようなアプリは作ろうと思えば作れるよなぁ。
  • カスタムオブジェクトでいいんじゃない?

といった疑問が残ります。

そこで、「あぁたしかにこれは便利だ!」と思えるような有効な使い方がないか、考えてみました。

免責事項

プラットフォームイベントはメッセージングの機構なので、メッセージをやり取りする主体には発行側(Pub)、購読側(Sub)があり、Pub/Subの組み合わせには以下の4パターンがあります。

Pub Sub
Salesforce Salesforce
Salesforce 外部ソース
外部ソース Salesforce
外部ソース 外部ソース

今回は3番目の、Pub=外部ソース、Sub=Salesforceのシナリオに絞った内容です。他のパターンでも(多分)有効な活用シーンが色々あるでしょう。

また、今回の話ですが、思いついたアイディアをメモ用に記事にしているだけなので、実現性については未検証です。そのうち試します(きっと)

【活用例1】大量のレコードを1個のイベントに詰めて登録し、SOAP APIコールの制限を抜ける

1個目は、「大量のレコードを最低限のAPI消費でSalesforceに登録したい」という要望を満たすために、複数のレコードをシリアライズして1個のプラットフォームイベントに詰めてPublishし、Salesforce(Subscriber)側でデシリアライズをしてからレコードを登録していく、というアプローチです。

そもそもSOAP APIコールの場合、createやupdateは1回で200レコードまでです。

developer.salesforce.com

それ以上のレコードを登録したい場合、APIコールを分けるか、Bulk APIの利用を検討する必要があります。APIコールを分ける場合、当たり前ですがAPIを余分に消費してしまいます。Bulk APIは、結果の評価を非同期で行う必要がありますし、扱いに癖があります。

やり方はこんな感じ。

  1. (Salesforce) ロングテキストエリア側のカスタム項目をもつプラットフォームイベントを作成
  2. (外部ソース) 外部ソースからAPIを利用してプラットフォームイベントをPublishする。その際、ロングテキストエリア項目に登録したいレコードをJSONシリアライズして詰めておく。
  3. (Salesforce) プラットフォームイベントのApexトリガの処理で、ロングテキストエリア項目をJSONシリアライズし、元のレコードを復元する。
  4. (Salesforce) 復元したレコードをINSERT/UPDATEする。

AWSKinesisでいう、Aggregation & Deaggregationライブラリと同じ発想ですね。割と常套手段なんでしょうか…。

【活用例2】活動(行動/ToDo)の関連先を動的に割り当てる

2個目は、「外部から登録する活動の関連先を動的に割り当てる」という要望を満たすために、直接 Event を登録するのではなく、中間にプラットフォームイベントを挟む、というアプローチです。

そもそも活動を外部からAPIで登録する場合、関連先(WhatId)項目の指定が必須であり、かつ、関連先にはSalesforce IDをセットしなければなりません。外部IDが使えないのは意外と不便だったりします。おそらく、どのオブジェクトに対する活動か、というのを術がないからだと思いますが。

間にプラットフォームイベントを挟んでしまえば、Apexトリガやプロセスビルダーで動的に関連付けができるのでは?と考えました。

まとめ

どちらの活用例にも共通して言えることは、直接 create/update するのではなく間にプラットフォームイベントをクッションとして挟むことで、柔軟性を持たせられる、ということですね。そう考えると、色々便利に使えそうな気がしてきます。

*1:ちなみにTrailheadはまだやってません…。

DynamoDBのTTL機能とLambdaを使ってサーバレスでのタイマー実行処理ができないか考えてみた

バッチ処理の中には、定期実行ではなく、特定の時刻に実行したい、みたいなものがあります。
例えば、今から5分後とか3日後の12:00とか。ジョブスケジューラでよくある感じのやつです。

AWSで、サーバレスなアーキテクチャ(実行基盤はAWS Lambda *1)でこの処理を実現する方法について考えました。

定期実行の場合

そもそも単純な定期実行の場合は、以下に書かれているように、CloudWatch Eventsを使うことで実現できます。

スケジュールされたイベントでの AWS Lambda の使用 - AWS Lambda

ただこの仕組みは「Rate または Cron を使用したスケジュール式 - AWS Lambda」で表現可能なスケジュール実行しかできないので、起動したい処理(タスク)がいっぱいあり、それらの指定開始時刻がバラバラな場合、使うのは難しそうです。

タイマー実行(スタンダード?な方法)

スタンダードなやり方だと、以下のどちらかでしょうか。

  • 処理タスクをキューに貯めて、定期的にポーリング
  • EC2などでQuartzのようなスケジューリング用のライブラリを使う

タイマー実行(DynamoDB)

今回考えた方法では、DynamoDBのTTL(Time to Live)機能を利用します。アイテムに設定された時刻(有効期限)になると、アイテムが削除されます。TTLを使った利用シーンとしては、キャッシュ、セッション管理、不要になった古いアイテムのパージなどですかね。

新機能 – TTL(Time to Live)機能を利用したDynamoDBアイテムの管理について | Amazon Web Services ブログ

重要なのは、TTLによってアイテムが削除された場合も、通常の追加・更新・削除と同様にDynamoDB Streamのトリガ(Stream)でイベントを発火させ、Lambdaで処理させることができる点です。

そのため、有効期限(TTL)として処理を開始したい時刻を設定することで、指定時刻でLambdaが実行できると考えました。

というわけで、簡単に試してみました。

で、結論ですが、「指定した時刻で」という条件を守るのは難しそうです。うーん、現実は厳しい。。
というのも、以下のドキュメントに書かれているとおり、DynamoDBの有効期限は、その時刻になったら削除されるというものではなく、ベストエフォートベース(具体的にいつ削除されるかは分からない)んですね。

docs.aws.amazon.com

今回は新規にテーブルを作って試したのですが、有効期限を00時26分35秒に作ったアイテムが実際に削除されたのは00時38分58秒(約12分後)でした。

Amazon Simple Workflow Service (SWF)

ここまで書いてAmazon Simple Workflow Service (SWF)の存在を思い出しました。
個人的にまだ使ったことはなく、名前にSimpleというワードを含んでいながらAWSの中でもトップクラスに複雑なサービスというイメージなんですが、どうなんだろう…。

*1:AWS Lambda単体で難しい場合、例えば、処理が複雑であったり、タイムアウトが起きるくらい実行時間がかかる場合は、AWS Step FunctionsやAWS Batchを使う(組み合わせる)イメージです。

Visual Studio Codeを使いはじめた

今までテキストエディタSublime Textを使っていたのですが、Visual Studio Codeを使い始めました。まだ使い始めたばかりですが、好感触なので、近いうちに完全に乗り換えようと思います。

感想

デフォルトでの使いやすさ

インストールした段階で、ほぼほぼエディタとして快適に書ける環境がそろっている印象です。
まぁ元々、テキストエディタについては、快適に書ける・読めるための最低限のチューニングしかしてなかったというのもありますが…。
Sublime Textでいう「Package Control」的なものも、個別インストールをすることなく最初から使えます。あと、デフォルトのカラーテーマ「Dark+」もいい感じです。

あと、Sublime Textとの比較でいうと、日本語入力も普通に使えますし、Shift_JISファイルを開くためのエンコードの設定も楽ですね。

起動も快適

気にならないレベルで起動します。
よく比較されるAtomは起動が遅い、というのを目にするので…(実際に使ったことはないので何とも言えないですが)

安定性についても今のところは全然問題ないです。

使い方・セットアップについての備忘録

編集

ショートカット

いっぱいあっても覚えられない人間なので、よく使うものだけ覚えていきます。

キー 用途
Ctrl + Shift + p コマンドパレットを開く (=F1)
Ctrl + o ファイルを開く
Ctrl + k + o フォルダを開く
Alt + Shift + f コードフォーマット
Ctrl + クリック クリックした定義に移動(=F12?)
Ctrl + \ 画面分割
Ctrl + 数字 ペイン切り替え

一括編集

  • Shift + Alt
    • 複数行
  • Alt
    • カーソルを追加

JSONフォーマット

  • Alt + Shift + f (コードフォーマットと同じ)

設定

[ファイル] > [基本設定]から。

  • 空白文字を表示
{
    "editor.renderWhitespace": "all"
}

拡張

Salesforceの開発でおなじみのMavensMateもAtom用のものがあるみたいですね。

アーカイブ済みの活動をクエリする時はFROM ActivityHistoriesではなくFROM Event ... ALL ROWSを使った方がよさそう #salesforce

あるオブジェクトに関連付いている活動履歴レコードをクエリする場合、SOQLで以下のようにクエリします(関連先のオブジェクトのIDが hogeId の場合)

SELECT
  Id, Subject, Description, StartDateTime, EndDateTime
FROM
  Event
WHERE
  WhatId = :hogeId
ORDER By
  StartDateTime DESC

ただし上記のクエリでは、アーカイブ済み(や削除済み)のレコードは取得できません。

アーカイブ済みのレコードを取得する方法は、調べた限りでは、少なくとも2つの方法がありそうです。

  • FROM ActivityHistories
  • FROM Event … ALL ROWS

パターン① FROM ActivityHistories

親オブジェクトから子(活動履歴)をクエリする方法(リレーションクエリ)です。

SELECT
  Id,
  (
    SELECT
      Id, Subject, Description, StartDateTime, EndDateTime
    FROM
      ActivityHistories
  )
FROM
  Parent__c
WHERE
  ID = :whatId

ちなみに、リレーションクエリを使わずに直接に ActivityHistory をクエリすると以下のエラーになります。

entity type ActivityHistory does not support query.

パターン② FROM Event ALL ROWS

ALL ROWS キーワードを付ける方法です。このキーワードを付けることによって、アーカイブ済み、削除済みのレコードも含めて取得対象になります。
ただし今回は削除済みのレコードは取得したくないので IsDeleted = false を条件に追加しています。

SELECT
  Id, Subject, Description, StartDateTime, EndDateTime
FROM
  Event
WHERE
  WhatId = :hogeId
  AND IsDeleted = false
ORDER By
  StartDateTime DESC
ALL ROWS

2つの書き方の比較

FROM ActivityHistories

  • メリット
    • クエリが1回で済む
  • デメリット
    • 「すべてのデータの参照」権限のないユーザがアクセスする場合、クエリ内部の句では以下の条件をすべて見なしていなければ、エラーになる。
      • WHEREを指定していないこと。
      • LIMIT 500(あるいはそれ以下の値)を指定し、取得レコード数を制限していること。
      • ORDER BYでレコードの取得順を指定する場合、ActivityDate の降順および LastModifiedDate の昇順の順番であること。

FROM Event … ALL ROWS

  • メリット
    • WHERE, LIMIT, ORDER BY に関する検索条件の指定に制限がない(とは言え、WhatIdで関連先オブジェクトを絞ったり、一般的なガバナ制限には気をつける必要はあります)
  • デメリット(運用上はデメリットにはならないと思いますが…)
    • ALL ROWS キーワードは開発者コンソール(Query Editor)では利用できない(Apexからのみ利用可)

ActivityHistoriesを使ったリレーションクエリの場合、デメリットが大きいんですよね。
現実問題、「すべてのデータの参照」権限のないユーザが利用することは普通のケースだと思います。というわけで、上記のルールの上でがんばるよりは、おとなしく FROM Event … ALL ROWS を使った方がよさそう、という結論に至りました。

ちなみに、制限に違反していると、以下のようなエラーに遭遇します。

  • LIMIT指定がない場合

There is an implementation restriction on ActivityHistories. When you query this relationship, security evaluation is implemented for users who don’t have administrator permissions, and you must specify a LIMIT with a maximum of 500

  • ORDER BY の指定が想定外場合

There is an implementation restriction on ActivityHistories. When you query this relationship, security evaluation is implemented for users who don’t have administrator permissions, and you must use a specific sort order: ActivityDate DESC, LastModifiedDate DESC

このあたり、以下のURLに書かれていることなんですが、「次の制約は、パフォーマンスの問題を回避するのに役立ちます。」みたいな言い回しよりも、はっきりとエラーになる旨を書いておいてほしい…。

developer.salesforce.com

AWS CodeBuildで依存ライブラリのキャッシュを利用する

会社ブログに書きましたが、その続き、というかAWS CodeBuildのビルド時間を短縮するための小ネタです。

AWS CodeBuildはビルド時に新しく実行環境を起動するため、クリーンな環境でのビルドが行える一方、jarなりgemなりの依存ライブラリをビルドのたびにダウンロードしてしまうので、ビルド時間がかさんでしまいます。

今のところサービス標準の機能として、特定のディレクトリをキャッシュして再利用する、みたいなことはできないみたいなので、 buildspec.yml 側で対応する仕組みを考えてみました。

Gradle の場合、こんな感じです(ポイントだけ抜粋)

version: 0.2

phases:
  install:
    commands:
      - aws configure set s3.signature_version s3v4
      - aws s3 cp --region ap-northeast-1 $GRADLE_CACHE_S3_URL /tmp/gradle-caches.tar.gz
      - mkdir -p $GRADLE_USER_HOME/caches
      - tar -zxf /tmp/gradle-caches.tar.gz -C $GRADLE_USER_HOME/caches
  build:
    commands:
      - ./gradlew build
  post_build:
    commands:
      - tar -zcf gradle-caches.tar.gz -C $GRADLE_USER_HOME/caches .
artifacts:
  files:
    - gradle-caches.tar.gz
  discard-paths: no

ビルド後の post_build フェーズ時 *1$GRADLE_USER_HOME/caches の中身を tarball にし、成果物(アーティファクト)としてS3にアップロードします。毎回のビルド開始時にはそのファイルを展開する、という方法です。 tarball の中身が足りない場合は ./gradlew build 実行時に不足分のライブラリがダウンロードされるはずです。

ちなみに初回はS3にファイルがないので、その部分の対応は必要になります(今回は省略)。

*1: この時点ではすべての依存ライブラリのダウンロードが終わっているはずです。