かもメモ

自分の落ちた落とし穴に何度も落ちる人のメモ帳

JavaScript (node.js) ioredis 使ってみたのメモ

Redis との接続

const Redis = require('ioredis');
const redis = new Redis();
// docker などで動かしている場合は port host を指定する
const redis = new Redis(6379, 'redis');
// 使用する db を指定する場合は、 オプションを使用
const redis = new Redis(6379, 6379, {db: 1});

String 型

// set
redis.set('cute', '星宮いちご'); // => 'OK'

// get
redis.get('cute'); // => '星宮いちご'
redis.get('cool'); // => null

// delete
redis.del('cute'); // => 削除できた場合は1, keyが無かった場合は0

// 期限付きでset 期限は秒で指定
redis.set('cool', '霧矢あおい', 'EX', 60); // => 'OK'

// 複数の値を作成
redis.set(cute', '星宮いちご', 'cool', '霧矢あおい', '...');
// => syntax error (redis のコマンド的な書き方はできない)
// 👇
// multi, pipeline を使う
redis
  .multi()
  .set('cute', '星宮いちご', 'EX', 60)
  .set('cool', '霧矢あおい', 'EX', 60)
  .set('sexy', '紫吹蘭', 'EX', 60)
  .exec(); // => [ [ null, 'OK' ], [ null, 'OK' ], [ null, 'OK' ] ]

// 複数の値を削除
redis.del('cute', 'cool', 'sexy'); // => 3 削除に成功した数が帰る

同じ key に set した場合値は上書きされる
.multi(), pipeline() で複数の処理をした場合、exec() が実行された時に実際に redis に登録されるので、有効期限など同じにすることができるっぽい。

If you want to send a batch of commands (e.g. > 5), you can use pipelining to queue the commands in memory and then send them to Redis all at once. This way the performance improves by 50%~300%
redis.pipeline() creates a Pipeline instance. You can call any Redis commands on it just like the Redis instance. The commands are queued in memory and flushed to Redis by calling the exec method
cf GitHub - luin/ioredis: 🚀 A robust, performance-focused and full-featured Redis client for Node.js.

List 型

// リスト型を作成して値を追加
// key, value, value, value, … で作成
redis.rpush('solie', '星宮いちご', '霧矢あおい', '紫吹蘭'); // 3 リストの要素数が返る

// リストの内容を全取得
redis.lrange('solie', 0, -1);
// => [ '星宮いちご', '霧矢あおい', '紫吹蘭' ]

// get は使えない
redis.get('solie'); // => syntax error

// index の値を取得
redis.lindex('solie', 2); // => '紫吹蘭'

// リストの先頭を取り出して削除
redis.lpop('solie'); // => '星宮いちご'

// リストの最後を取り出して削除
redis.rpop('solie'); // => '紫吹蘭'

// リストの先頭に追加する
redis.lpush('tristar', '神崎美月', '藤堂ユリカ', '紫吹蘭');
// => tristar: ['紫吹蘭', '藤堂ユリカ', '神崎美月']

// index の値を置き換え
redis.lpush('tristar', 0, '一ノ瀬かえで');
// => tristar: [ '一ノ瀬かえで', '藤堂ユリカ', '神崎美月' ]

// リストから指定した value の要素を削除
redis.lrem('tristar', 0, '神崎美月');
// => tristar: [ '一ノ瀬かえで', '藤堂ユリカ' ]

// lrem の第2引数は先頭から削除する要素数を指定できる
redis.rpush('arr', 1, 2, 3, 1, 2, 3, 1, 2, 3);
// 0 の場合は該当する要素全て削除する
redis.lrem('arr', 0, 1);
// => arr: [ '2', '3', '2', '3', '2', '3' ]

// 1 を指定すると最初の1つめの要素だけ削除される
redis.lrem('arr', 1, 2);
// => arr: [ '3', '2', '3', '2', '3' ]

// 2 を指定すると先頭から2つ要素が削除される
redis.lrem('arr', 2, 3);
// => arr: [ '2', '2', '3' ]

// list 型のデータを削除
redis.del('arr');

Hash 型

// ハッシュ型を作成してデータを追加
// key, field, value, field, value … でデータを作成
redis.hset('luminas', 'cute', '大空あかり', 'cool', '氷上すみれ', 'pop', '新条ひなき');

// field の値を取得
redis.hget('luminas', 'cute'); // => '大空あかり'

// field を削除
redis.hdel('luminas', 'cool');
// => luminas: { cute: '大空あかり', pop: '新条ひなき' }

// データまとめて追加
// hmset は追加するデータをオブジェクトで指定できる
redis.hmset('luminas', {'cool': '氷上すみれ', 'sexy': '紅林珠璃'});
// => luminas: { cute: '大空あかり', pop: '新条ひなき', cool: '氷上すみれ', sexy: '紅林珠璃' }

// 複数 field の値をまとめて取得
redis.hmget('luminas', 'pop', 'sexy'); // => [ '新条ひなき', '紅林珠璃' ]

// field を全て取得
redis.hkeys('luminas');
// => [ 'cute', 'pop', 'sexy', 'cool' ]

// value を全て取得
redis.hvalss('luminas');
// => [ '大空あかり', '新条ひなき', '紅林珠璃', '氷上すみれ' ]

// field, value を全て取得
redis.hgetall('luminas');
// => { cute: '大空あかり', pop: '新条ひなき', sexy: '紅林珠璃', cool: '氷上すみれ' }

// hash 型のデータを削除
redis.del('luminas');

ioredishgetall は redis のコマンドとは異なりオブジェクトを返すので map などを使おうとしたらエラーになるので注意が必要

List 型 / Hash 型に有効時間を設定

List 型 / Hash 型の set メソッドには EX キーワードで有効時間を設定できる方法がないので、.pipeline() で繋いで .expire(key, time) で設定する。
pipeline は特に前に操作した key などを保持しているわけではないので、続けて実行する .expire にもキーを指定する必要がある

redis
  .pipeline()
  .rpush('solie', '星宮いちご', '霧矢あおい', '紫吹蘭')
  .expire('solie', 60)
  .exec();
// => [ '星宮いちご', '霧矢あおい', '紫吹蘭' ] ttl: 60

redis
  .pipeline()
  .hset('luminas', 'cute', '大空あかり', 'cool', '氷上すみれ', 'pop', '新条ひなき')
  .expire('luminas', 60)
  .exec();
// => { cute: '大空あかり', cool: '氷上すみれ', pop: '新条ひなき' } ttl: 60

// 有効期限は `ttl(key)` で取得できる
await new Promise((resolve => setTimeout(resolve, 5000))); // 5 秒停止
redis.ttl('solie');
// => 55

 
基本的にはこのくらいのコマンドでそれなりの事ができそうです。 他にも Pub/Sub など色々なことができるみたいなので、必要になったら触ってみたいと思います。


[参考]

いちばん大切なのに誰も教えてくれない段取りの教科書

いちばん大切なのに誰も教えてくれない段取りの教科書

  • 作者:水野 学
  • 発売日: 2018/10/11
  • メディア: 単行本(ソフトカバー)
👆 安定と信頼の水野学さんの本たいへんよかったです。

Expresss ioredis で Redis 入門した

JWT token の勉強をしていて、Redis で token を管理しているという話を教えてもらったので Redis を使うだけの環境を作ってみていました。

Docker で環境を構築します

構成

/
|- /api (express)
|    |- Dockerfile
|- /redis
|- docker-compose.yml

docker-compose.yml

version: '3'
services:
  redis:
    image: redis:latest
    volumes:
      - ./redis/data:/data
    ports:
      - 6379:6379

  api:
    build:
      context: './api'
      dockerfile: 'Dockerfile'
    volumes:
      - ./api:/api
      - ./api/package.json:/api/package.json
      - ./api/package-lock.json:/api/package-lock.json
      - ./api/node_modules:/api/node_modules
    ports:
      - 3000:3000
    depends_on:
      - redis
    tty: true
    working_dir: "/api"
    command: bash -c "npm run start"

/api/Dockerfile

FROM node:12.18.0

WORKDIR /api

build

$ docker-compose build
$ docker-compose up

redis と api のコンテナが動いてたらOK
今回はモックのAPIなので redis 特に設定せずに使います。(production で使う場合は config を作って読み込ませるとか必要な気がします)

これくらいだとMacでも速いですね。

ioredis で api から Redis に接続する

apiディレクトリに移動してパッケージをインストールします。

$ npm install express ioredis

開発環境なら nodemon を入れて "start": "nodemon ./index.js" とかとしておくと楽です。

api/index.js

const express = require('express');
const app = express();
const PORT = 3000;

// Router
const authRouter = require('./routes/auth');

// Middleware
// Express v4.16.0 から core に戻ったみたいなので Body-Parser をインストールしなくてもOK
app.use(express.json());
app.use(express.urlencoded({ extended: true }));

// Routing middleware
app.use('/api/user', authRouter);

app.listen(PORT, () => console.log(`SERVER started on localhost:${PORT}`));

./routes/auth.js

const router = require('express').Router();
const Redis = require('ioredis');
const redis = new Redis(6379, 'redis');

router.post('/login', async (req, res) => {
  // Moc user
  const user = {id:1, name: '星宮いちご'}
  // ID を key に String 型でデータを保存 'EX', time (second) で有効期限を設定できる
  await redis.set(user.id, JSON.stringify(user), 'EX', 60)
  return res.send(user);
});

router.get('/posts', async (req, res) => {
  const userID = req.body.id;
  try {
    // redis から データを取得
    const data = await redis.get(userID);
    // redis 上に存在してなかった場合 err ではなく null が返される
    if (!data) {
      throw new Error('Access Denied.');
    }
    return res.send(JSON.parse(data));
  } catch (err) {
    return res.status(400).send(err.message);
  }
});

module.exports = router;

セキュリティとか何もない感じですが、docker-compose up して、localhost:3000/api/users/login に POST でアクセスすると user データが Redis に保存されます。その後に localhost:3000/api/users/posts{id: 1} を持たせてアクセスすると Redis のデータが有効期限内なら user データが返され、期限切れだと Access Denied. が返るようになっているかと思います。

Redis のデータの確認

docker のコンテナに入って redis-cli を使って確認します。

$ docker exec -it <Redis_container_name> bash
$ redis-cli
# 登録されているキーの確認
127.0.0.1:6379> keys *
# キーの値を確認 (String 型)
127.0.0.1:6379> get key
# キーの有効期限を確認
127.0.0.1:6379> ttl key
# キーを削除
127.0.0.1:6379> del key
# データを作成 (String型)
127.0.0.1:6379> set key value
# キーに有効期限を設定
127.0.0.1:6379> expire key time

ほぼ ioredis からのコマンドと同じで redis の確認・操作をすることができました。
有効期限の設定付きでデータを作成する場合 ioredis では redis.set(key, value, 'EX', time) で丸っと作成することができましたが、redis で直接データをつくる際に set key value 'EX' time とすると、EX: time というデータが作られてしまいます。有効期限の設定は別途 expire コマンドで設定する必要がありました。

redis に入ってテキトーなキーで値を作成すると、作成したキーを id にしてlocalhost:3000/api/users/posts にアクセスすると保存した値が返されます。(JSON.parseがエラーにならなければ)

所感

別に express でなくても良かったと思いますが、ioredis を使うと簡単に JavaScript (node.js) で Redis を使うことができました。
1点だけポイントとしては、new Redis() のに渡しているポートの設定ですが docker の場合 Redis port, Redis host を docker-compose のport番号, コンテナ名 で指定する必要がありました。
config を作れば、コンテナ名でなくポート番号で指定できるようになるのかもしれません。

JWT の実験や Redis で List 型や Hash 型の操作もしてみたので、それは追々メモ書いていきたいと思います。

おわり。


[参考]

I would recommend you create two separate clients each connect to a different database instead of using select to avoid potential conflictions.
cf. question on multiple databases · Issue #466 · luin/ioredis · GitHub

ioredis では select() を使うより new Redis({db: 1}) のように別クライアントを作るほうが良いっぽい

自転車えくすぷれす

自転車えくすぷれす

  • 発売日: 2014/04/01
  • メディア: MP3 ダウンロード

さよぽに の「自転車えくすぷれす」いい曲なので聴いて!

JavaScript catch したエラーをエラーの種類で別の処理にしたい

1つの catch 節でエラーをキャッチして、エラーの種類で処理を変えたい場合のメモ。

e.g.

function verify(token) {
  try {
    // 期限切れの場合 `TokenExpiredError` の例外が発生
    const verified = jwt.verify(token, process.env.TOKEN_SECRET);
    
    // revoke されてないか確認
    const validated = checkValidityToken(token);
    if (!validated) {
      return res.status(400).send('Token Expired');
    }
    
    return res.data = verified;
  } catch (err) {
    if (err instanceof TokenExpiredError) {
      return res.status(400).send('Token Expired');
    }
    // token が不正
    return res.status(400).send('Invalid Token');
  }
}

独自の checkValidityToken 関数で false が返された時に catch 節に処理を流して TokenExpiredError と同じ処理にしたい。

throw new Error

new Error(message) を throw すると catch 節の引数で取ることができる。

try {
  throw new Error('💩');
} catch (err) {
  console.log(err.name, err.message); // => Error 💩
}

new Error で作られたエラーオブジェクトの name プロパティは Error
MDN の Error 特定のエラーを処理する には Error クラスを extends で拡張して、error instanceof ErrorClassName でエラークラスで処理を分ける方法が載っていますが、error.name を利用すれば既存のライブラリで定義されているエラークラスに見せかけることができそうです。

Error.prototype.name

name ロパティは、エラーの種類の名称を表します。初期値は "Error" です。

var e = new Error('Malformed input'); // e.name is 'Error'
e.name = 'ParseError';
throw e;
// e.toString() would return 'ParseError: Malformed input'
cf. Error.prototype.name - JavaScript | MDN

name プロパティを利用して catch 節で error.name で処理を分岐させればOK。
元のコードを書き換えます

function verify(token) {
  try {
    // 期限切れの場合 `TokenExpiredError` の例外が発生
    const verified = jwt.verify(token, process.env.TOKEN_SECRET);
    
    // revoke されてないか確認
    const validate = checkValidityToken(token);
    if (!validate) {
      const validateError = new Error('Token Expired');
      validateError.name = 'TokenExpiredError';
      throw validateError;
    }
    
    return res.data = verified;
  } catch (err) {
    if (err.name === 'TokenExpiredError') {
      return res.status(400).send('Token Expired');
    }
    // token が不正
    return res.status(400).send('Invalid Token');
  }
}

₍ ᐢ. ̫ .ᐢ ₎👌 A W E S O M E !

本当は TokenExpiredError を import してエラーオブジェクトを作成するのが正しいのだと思いますが、error.name を使っても同じ様な処理を実現することができました。ライブラリ内の実装読まなくて済むので特に問題が発生しないならこの方法の方が楽そうです。


[参考]

エラーキャッチ☆プリキュア