MomentoとMySQLでCache-Asideパターンを実装してみる

2022.12.22

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

Introduction

Developers.ioでも度々紹介してますが、
Momento Serverless Cache(以下Momento)は
クラウドネイティブな高速キャッシュサービスで、下記のような特徴をもっています。

  • セットアップがとても簡単
  • プロビジョニングの必要がない(自動でうまくやってくれる)
  • 料金はデータの転送量($0.15/GB)のみ。月50GBまでは無料

今回はJavaScript版のMomento SDKをつかって、
Momentoのユースケースである
「RDBのキャッシュとしての用途」について紹介します。

Cache-Aside Pattern

キャッシュを用いたアーキテクチャにはいくつかのパターンがありますが、
ここでは最もシンプルなCache-Asideについて紹介します。

このパターンはまずキャッシュにデータがあるかリクエストして、
あった場合はキャッシュからデータを返します。
なかった場合、データ元のストレージ(RDBやS3など)にアクセスして
データを取得し、それをキャッシュに保存して返します。

シンプルなWebアプリのリクエストを例として
Cache-Asideを図にすると↓のようなイメージです。

  1. クライアントからリクエスト
  2. キャッシュ(Momento)にヒットするデータかチェック。あればそのデータを返す
  3. キャッシュにヒットしなければ、データストアからデータ取得
  4. データストアから取得したデータをキャッシュに保存

これ以外にデータの更新・削除・同期など、
実際のシステムでは考慮しなければいけない問題が
ありますが、とりあえず今回はシンプルに実装してみます。

Environment

  • MacBook Pro (13-inch, M1, 2020)
  • OS : MacOS 12.4
  • Node : v18.2.0
  • MySQL : Ver 8.0.31

Setup

今回はデータストレージにMySQL、キャッシュにMomentoを使って、
シンプルなWebアプリでCache-Aside処理を実装してみます。
ユーザーデータの取得と登録をするAPIを実装し、
id指定してユーザーデータを取得時、
キャッシュにデータがあればそれを返すようにします。

では各種環境をセットアップしましょう。

Momentoのセットアップ

まずはキャッシュ機能であるMomentoのセットアップです。
このあたりを参考に認証トークンを取得しましょう。
あとでjavascriptのprocess.envから使うので、
コンソールでexportしておくとよいです。

% export MOMENTO_AUTH_TOKEN=<認証トークン>

データストレージのセットアップ

今回はローカルにMysQLを用意します。
適当にMySQLをインストール後、
下記のようにデータベースだけ用意します。
テーブルはあとでPrismaを使ってつくるので、
いまは必要ありません。

% mysql
mysql> create database mydb;

NestJSのセットアップ

今回、API実装のためのフレームワークにはNestJSを使います。
npmでNestJSをインストールし、デモアプリの雛形を作成します。

% npm i -g @nestjs/cli
% nest new momento-nest

package managerはnpmを選択しました。
起動してlocalhst:3000にアクセスできればOKです。

% cd momento-nest
% npm run start

Implementation Programs

では実装していきましょう。

PrismaでDBアクセス

今回mysqlへのアクセスはPrismaを使います。
PrismaはTypeScript/JavaScriptで使えるORMです。

ここに基本的な手順がかいてるので、
これにそって進めていきます。

Prismaライブラリをインストール後、npxで初期化実行。

% npm install prisma --save-dev
% npx prisma init

プロジェクトディレクトリにprismaディレクトリができているので、
schema.prismaを修正。
ここにMysqlの接続情報とテーブル(モデル)情報を記述します。

// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema

generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "mysql"
  url      = "mysql://<ユーザー名>:@localhost:3306/<データベース名>"
}


model User {
  id    Int      @default(autoincrement()) @id
  name  String
  age   Int
}

ここでmigrateを実行すれば、shemaファイルの定義にそって
MySQLのテーブルつくったりクライアントコードの生成をしてくれます。

% npx prisma migrate dev --name init

#クライアントモジュールのインストール
% npm install @prisma/client

そしてsrc/prisma.service.tsファイルを下記のように作成します。
これはPrismaの接続などを管理するクラスです。

import { INestApplication, Injectable, OnModuleInit } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';

@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit {
  async onModuleInit() {
    await this.$connect();
  }

  async enableShutdownHooks(app: INestApplication) {
    this.$on('beforeExit', async () => {
      await app.close();
    });
  }
}

app.module.tsにクラスを登録。
とりあえず今回のmoduleはこれだけ使います。

・・・
@Module({
  imports: [],
  controllers: [AppController],
  providers: [AppService,PrismaService],
})
export class AppModule {}

Momento用サービスの作成

次はMomentoにアクセスするserviceを作成します。
最初にMomento用SDKをインストールします。

% npm install --save @gomomento/sdk

そして、srcディレクトリにmomento.service.tsファイルを作成し、
下記内容を記述します。

import { INestApplication, Injectable, OnModuleInit } from '@nestjs/common';
import { SimpleCacheClient } from '@gomomento/sdk';

@Injectable()
export class MomentoService {

  private momento:SimpleCacheClient;
  private cache:string = '<キャッシュ名>';

  constructor() {
    let token:string = process.env.MOMENTO_AUTH_TOKEN;
    this.momento = new SimpleCacheClient(token, 30);
  }

  //キャッシュ保存用
  async setCache(key:string,value:string) {
    const setResult = await this.momento.set(this.cache, key,value);
    console.log(`set Result:${setResult.text()}`);
  }

  //キャッシュ取得用
  async getCache(key:string):Promise<string> {
    const getResult = await this.momento.get(this.cache, key);
    return getResult.text();
  }
}

キャッシュ名やTTLは環境にあわせて決めてください。
その後、app.module.tsにPrismaServiceの登録を忘れずに。

ユーザーデータ用のController&Service

ユーザー管理用のコントローラーとサービスを生成。
GET /users/で任意のIDのユーザーデータをMySQLから取得、
POST /usersでユーザー情報の登録をします。

% nest g controller users
% nest g service users

これでsrc/users以下にコントローラーとサービスが作成されました。
各クラスを実装していきましょう。

user.service.tsは下記。
PrismaServiceをつかってuserテーブルに対しての
登録と検索をしてます。

import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma.service';
import { User, Prisma } from '@prisma/client';

@Injectable()
export class UsersService {
    constructor(private prisma: PrismaService) {}

    async findById(
        id:number,
      ): Promise<User | null> {
        return this.prisma.user.findUnique({
          where: {'id':id},
        });
    }

    async findAll(): Promise<User[]> {
        return this.prisma.user.findMany({});
    }

    async create(data: Prisma.UserCreateInput): Promise<User> {
        return this.prisma.user.create({
          data,
        });
    }
}

シンプルにしたいのでかなり実装端折ってます。

そしてコントローラークラスの実装内容はこんなかんじです。

@Controller('users')
export class UsersController {

    //ユーザーリスト取得
    @Get()
    findAll(): Promise<User[]> {
        return this.usersService.findAll();
    }    

    //ユーザー登録
    @Post()
    create(@Body() userDto: UserDto): Promise<User> {
      return this.usersService.create(userDto);
    }

    //ID指定してユーザー取得
    @Get(':id')
    async find(@Req() req: Request,@Param('id') id:string): Promise<User> {
        //データストアから値取得
        console.log("Get User from MySQL!");
        let user = await this.usersService.findById(parseInt(id));
        //momentoに登録
        this.momentoService.setCache(req.url,JSON.stringify(user));
        return user;
    }
}

findではMySQLからデータを取得したあと、
MomentoにURLをキーとして保存してます。

↑で使ってるDTOの定義。

//user.dto.ts
export class UserDto {
    name: string;
    age: number;
}

動作確認してみます。
curlでユーザー情報の登録と取得をやってみます。

%npm run start

//ユーザー情報登録
% curl -X POST -H "Content-Type: application/json" -d '{"name":"taro", "age":30}' http://localhost:3000/users

//ユーザー取得
% curl http://localhost:3000/users/1
{"id":1,"name":"taro","age":30}

MySQLへのアクセスは問題なさそうです。

Cache-Asideの実装

では、キャッシュ機能を作成します。
今回はNestのInterceptorをつかって実装します。
この機能はAOP的なもので、
メソッド実行の前後に追加のロジックを実行したりすることが可能になります。

では、srcディレクトリにcache.intercepter.tsファイルを作成して
下記のように実装しましょう。

import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common';
import { Observable, of } from 'rxjs';
import { MomentoService } from './momento.service';

@Injectable()
export class CacheInterceptor implements NestInterceptor {

    constructor(private momentoService: MomentoService) { }

    async intercept(context: ExecutionContext, next: CallHandler): Promise<Observable<any>> {
        let req = context.switchToHttp().getRequest();
        let key = req.path;

        let cacheData = await this.momentoService.getCache(key);
        if (cacheData !== null) {
            console.log("Get User from Momento!");
            return of(cacheData);
        }
        return next.handle();
    }
}

リクエストのパスをキーにしてMomentoへアクセスし、
データが見つかればそのデータを返します。
見つからなければコントローラーへ処理を移譲します。

インターセプターはデコレータをつかって設定します。
UserControllerのfindメソッドに対して
UseInterceptorsでさきほどのCacheInterceptorを設定します。

・・・
    @UseInterceptors(CacheInterceptor)
    @Get(':id')
    async find(@Req() req: Request,@Param('id') id:string): Promise<User> {
        //データストアから値取得
        console.log("Get User from MySQL!");
        let user = await this.usersService.findById(parseInt(id));
        //momentoに登録
        this.momentoService.setCache(req.url,JSON.stringify(user));
        return user;
    }
・・・

では再度、ID指定で何度かユーザーデータを取得してみます。  

//ユーザー取得
% curl http://localhost:3000/users/1
{"id":1,"name":"taro","age":30}
・
・
・

ログを見てみると、最初はMySQLからデータを取得してますが、
キャッシュの生存期間中であればMomentoからデータをかえしていることがわかります。

Get User from MySQL! <- 初回のアクセス
Get User from Momento! <- 2回目のアクセス
・
・
Get User from MySQL! <- TTL切れのあとのアクセス

Summary

今回はNestJS + MySQL + Momentoで
Cache-Asideを実装してみました。
NestJSのインターセプターで透過的に
実装できているのがいい感じです。

今回はローカルのMySQLを使ってますが、
RDSでも違いはありません。

この記事でキャッシュに使用したMomentoについてのお問い合わせはこちらです。
お気軽にお問い合わせください。

Momentoセミナーのお知らせ

2023年1月13日(金) 16:00からMomentoのセミナーを開催します。
興味があるかたはぜひご参加ください。

Reference