尋常でないもふもふ

a software engineer blog

Node.jsでRedis使うならioredisがおすすめ

TL;DR

  • まだ node_redis を使ってる人が多いけど標準で Promise 対応してなくてレガシー
  • ioredis
    • 使い方はほぼいっしょ
    • 標準で Promise 対応してるので async/await でそのまま書ける
    • Cluster, Sentinel, LuaScripting 含めたフル機能が使える

開発者の Luin さん

  • 元 Alibaba のエンジニア
  • Redis の GUI ツール Medis も開発

使い方

$ yarn install ioredis

async/await

もちろん従来の callback 方式でも書けるんだけど、async/await 覚えちゃうと callback には戻りたくない。

const Redis = require('ioredis');

(async () => {
  const redis = new Redis();
  const pong = await redis.ping();
  console.log(pong); // => PONG

  redis.disconnect();
})();

ちなみに node_redis で async/await する場合

公式から引用。promisify をかまさないといけないのが非常にだるい。

const {promisify} = require('util');
const redis = require("redis");

const client = redis.createClient();
const getAsync = promisify(client.get).bind(client);

(async () => {
  const res = await getAsync('foo');
  console.log(res);
})();

TypeScript で使う場合

$ yarn install ioredis
$ yarn install -D @types/ioredis

型情報が必要になるので IORedis として import したほうがわかりやすいと思う。

import * as IORedis from 'ioredis';

export class Sample {
  private readonly redis: IORedis.Redis;

  constructor(options?: IORedis.RedisOptions) {
    this.redis = new IORedis(options);
  }

  async echo(message: string): string {
    return await this.redis.echo(message);
  }
}

ランキングを実装してみる

ioredis を使ったデイリーランキングの実装例。

import * as IORedis from 'ioredis';
import {DateTime} from 'luxon';

import {RankingUser} from './ranking-user';
import {UserDto} from './user-dto';
import {RankingUtil} from './ranking-util';

export class DailyRanking {
  private readonly redis: IORedis.Redis;

  constructor(options?: IORedis.RedisOptions) {
    this.redis = new IORedis(options);
  }

  // e.g. RANKING_DAILY_20181016
  static createKey(): string {
    return DateTime.utc().toFormat("'RANKING_DAILY_'yyyyMMdd");
  }

  update(user: RankingUser, score: number): void {
    const key = DailyRanking.createKey();
    const dto: UserDto = {name: user.name, grade: user.grade}; // ignore userId, score
    const json = JSON.stringify(dto);
    this.redis.zadd(key, `${score}`, `${user.userId}:${json}`);
  }

  async listByHighScore(limit: number): Promise<RankingUser[]> {
    const key = DailyRanking.createKey();
    const max = '+inf';
    const min = '-inf';
    const args = ['LIMIT', '0', `${limit}`, 'WITHSCORES'];
    const result = await this.redis.zrevrangebyscore(key, max, min, ...args);
    const users: RankingUser[] = [];
    for (let i = 0, len = result.length; i < len; i++) {
      if (i % 2 === 1) {
        const member = result[i - 1];
        const score = result[i];
        const user = RankingUtil.createRankingUser(member, score);
        users.push(user)
      }
    }
    return users;
  }

  async getByUserId(userId: number): Promise<RankingUser> {
    const key = DailyRanking.createKey();
    const args = ['MATCH', `${userId}:*`];
    const [cursor, result] = await this.redis.zscan(key, 0, ...args);
    const [member, score] = result;
    return RankingUtil.createRankingUser(member, score);
  }

  close(): void {
    this.redis.disconnect();
  }
}

Redis の SortedSet では key, member, score の 3 つを格納してスコアを元に順位付けする。
一般的には member にユーザIDだけ入れて取得した後で RDB から最新データを取ってくる実装が多いと思うけど、上記コードではユーザ情報を JSON 文字列として Redis のなかに格納してしまう方式。
member の接頭辞を <user_id>: としておくことで zscan 使えばユーザIDで取得することもできる。

フルソースコードはこちら => GitHub

ES6 以降の数値⇒文字列変換

ちなみに memberscore も文字列で格納しなければいけないけど、数値変換する場合は Template String 使うのが一番高速。

const s1 = `${score}`; // Fast!!
const s2 = score + '';
const s3 = String(score);
const s4 = score.toString(); // 暗黙の型変換が行われるので一番遅い

まぁ for 文で 10,000 回転とかしなければ差異ないけど。

ioredisで謎のエラー

ioredis で謎のエラーがでてハマった。

[ioredis] Unhandled error event: ParserError: Protocol error, got "J" as reply type byte. Please report this.
    at handleError (/Users/jnst/go/src/github.com/Translimit/park-server/node_modules/redis-parser/lib/parser.js:190:15)
    at parseType (/Users/jnst/go/src/github.com/Translimit/park-server/node_modules/redis-parser/lib/parser.js:304:14)

エラーがでないコード

空のコンストラクタを使うとデフォルトで 127.0.0.1 に接続する。

import * as Redis from 'ioredis';

const redis = new Redis();

エラーがでるコード

hostport を指定するとエラー。

import * as Redis from 'ioredis';

const redis = new Redis(3306, '127.0.0.1');

原因

Redis の 6379 番ポートではなく、MySQL の 3306 番ポートにアクセスしてた。
このエラーだけならすぐ気づいたかもだけど、別のエラーとの複合技だったので解決に時間がかかってしまった。疲れてるようだ…。

TypeScriptとioredisのコードをJestでテストしたらコンストラクタじゃないと言われた件

TypeScript と ioredis のソースコード

TypeScript で Redis 使いたい場合は ioredis で書いてこんな感じのコードになる。

sample.ts

import * as Redis from 'ioredis';

(async () => {
  const redis = new Redis();
  const pong = await redis.ping(); // => PONG
})();

Jest のテストコード

ところが Jest でテスト書いたら実行できなくてハマってしまった。

sample.spec.ts

import * as Redis from 'ioredis';

describe('Redis', () => {

  it('should be return pong', async () => {
    const redis = new Redis();
    const pong = await redis.ping();
    redis.disconnect();
    expect(pong).toBe('PONG');
  })

});

.ts ファイルを直接実行するので ts-jest を入れている。

$ jest
    TypeError: Redis is not a constructor

      4 |
      5 |   it('should be return pong', async () => {
    > 6 |     const redis = new Redis();
        |                   ^
      7 |     const pong = await redis.ping();
      8 |     redis.disconnect();
      9 |     expect(pong).toBe('PONG');

      at Object.it (src/sample.spec.ts:6:19)

TypeError: Redis is not a constructor

なんでやねん

import * as Redis from 'ioredis';
const redis = new Redis();

のところでコンストラクタじゃないと言われる。書き方は正しいのに。

import Redis from 'ioredis';

に直したら動いた。間違ってるのに。
まぁそんなこともあるのかなと思ったりもしたけど、テストファイルをコンパイルして .js ファイルにしてテスト実行してみるとエラーになる。

    TypeError: ioredis_1.default is not a constructor

      4 | describe('Redis', () => {
      5 |     it('should be return pong', async () => {
    > 6 |         const redis = new ioredis_1.default();
        |                       ^
      7 |         const pong = await redis.ping();
      8 |         redis.disconnect();
      9 |         expect(pong).toBe('PONG');

      at Object.it (dist/sample.spec.js:6:23)

TypeError: ioredis_1.default is not a constructor
やっぱり書き方が間違ってるいるわけだ。

原因

ts-jest にバグがあったようだ。

"devDependencies": {
    "@types/ioredis": "^4.0.3",
    "@types/jest": "^23.3.5",
    "jest": "^23.6.0",
    "ts-jest": "^21.2.4",
    "typescript": "^3.1.3"
  }

yarn upgrade --latest ts-jest して 23.10.4 にしたら治った。