yak shaving life

遠回りこそが最短の道

Spring Data RedisでZPOPMIN(MAX)を使う

RedisにはSorted Setというデータ型があって、ユーザランキングみたいなものを作るときに便利だったりする。このSorted Setに対する操作はZADDとかZRANKのようにZから始まるものとなっている。

Sorted Setから一番スコアが低い(高い)ものを破壊的に取り出すZPOPMIN(ZPOPMAX)というコマンドがあり、Spring Data Redisでこれらを使いたかったのだがやり方がわからず、調べてもなかなか出てこないので苦労した。ので、解決までの道のりをメモ。やり方だけ知りたい人は最後の方のサンプルコードを見てください。検証に使っているSpring Data Redisのバージョンは2.5.6で、Javaは11。Redis ConnectorはLettuce。

まず、サポートされているコマンドは以下のように利用できる。

ZSetOperations<String, String> zSetOperations = redisTemplate.opsForZSet();
zSetOperations.add("ranking", "user1", 100);
zSetOperations.add("ranking", "user2", 200);
Set elements = zSetOperations.range("ranking", 0L, 1L);

System.out.println(elements);
// 出力 => [user1, user2]

zSetOperations.addZADDzSetOperations.rangeZRANGEコマンドを実行してます。簡単ですね。

ところがZSetOperationsクラスにはpopMinとかpopMaxみたいなメソッドがありません。こいつは困った。色々調べたけどよくわからない。そうこうしているうちに以下のissueに辿り着いた。

github.com

要は次くらいのバージョンで対応してZPOPMIN, BZPOPMIN, ZPOPMAX, BZPOPMAX, ZMSCOREが使えるようになるとのこと。へー。でも僕は!今やりたいんです!どうすりゃいいの!と思っていたら興味深いコメントが。

When using Lettuce, you need to provide a CommandOutput to capture the Redis response. For [B]ZPOP[MIN|MAX] that's new ScoredValueOutput<>(ByteArrayCodec.INSTANCE) (via execute(String command, @Nullable CommandOutput commandOutputTypeHint, byte[]... args)).

Spring Data Redis doesn't have yet command output hints for these commands.

なるほど、要はRedisTemplateではできないからLettuceのexecuteコマンドでやれってことね。ふむふむ。というわけでリファレンスを見る。

docs.spring.io

なるほどわからん…。数時間溶かしてなんとかできたのが以下。各行に何やってるかコメント書いてます。

 // ここはさっきとほぼ同じコード
ZSetOperations<String, String> zSetOperations = redisTemplate.opsForZSet();
zSetOperations.add("ranking", "user1", 100);
zSetOperations.add("ranking", "user2", 200);
Set elementsBefore = zSetOperations.range("ranking", 0L, 1L);
System.out.println("Ranking before ZPOPMIN : " + elementsBefore);
// => Ranking before ZPOPMIN : [user1, user2]

// LettuceConnectionを取得。キャストするのがミソ。キャストせずRedisConnectionのままだとexecuteが通らない
LettuceConnection conn = (LettuceConnection) redisTemplate.getRequiredConnectionFactory().getConnection();

// 実行。第2引数がミソ。戻り値はObject型
Object resultObject = conn.execute("ZPOPMIN", new ScoredValueOutput<>(ByteArrayCodec.INSTANCE), "ranking".getBytes());

// executeの戻り値はScoredValue型にキャストできる。ここの型を変えれば他のコマンドにも対応できそう
ScoredValue scoredValue = (ScoredValue) resultObject;

// getValueでvalueが取れるが、戻り値はObject型
Object valueObject = scoredValue.getValue();

// Stringを入れてるならStringに変換できる。直接はキャストできないのでbyte配列にしてから
String value = new String((byte[]) valueObject);
        
// scoreはdoubleで入ってるのでキャストとかは不要
double score = scoredValue.getScore();
        
// valueとscoreが取れてめでたしめでたし
System.out.println("Popped element : value = " + value + ", score = " + score);
// => Popped element : value = user1, score = 100.0

// POPしたのでSorted Setの中身が一つ減っていることを確認
Set elementsAfter = zSetOperations.range("ranking", 0L, 1L);
System.out.println("Ranking after ZPOPMIN : " + elementsAfter);
// => Ranking after ZPOPMIN : [user2]

conn.close();

このサンプルコードがググってさっと見つかればこんなに苦労することもなかったのになあ。。 ということで一応デモアプリをGithubにあげておいた。誰かの役に立てばいいけど。

github.com

追記:connectionをcloseしてなかったのでconn.close()を追加。