LRU Redux

JavaScriptReactReduxキャッシュライブラリLRU状態管理

GitHub概要

SamSaffron/lru_redux

An efficient optionally thread safe LRU Cache

スター286
ウォッチ4
フォーク20
作成日:2013年4月23日
言語:Ruby
ライセンス:MIT License

トピックス

なし

スター履歴

SamSaffron/lru_redux Star History
データ取得日時: 2025/10/22 08:07

キャッシュライブラリ

LRU Redux

概要

LRU Reduxは、Reduxアプリケーション向けのLRU(Least Recently Used)キャッシュ戦略を実装するライブラリコンセプトです。

詳細

LRU Reduxは、Redux状態管理パターンにLRU(Least Recently Used)キャッシュ戦略を統合する設計概念およびライブラリ群です。Reduxアプリケーションにおいて、APIレスポンス、計算結果、コンポーネント状態などのデータを効率的にキャッシュし、不要なAPIコールやレンダリングを削減します。Reselectライブラリのメモ化セレクターと組み合わせることで、派生データの計算コストを削減し、Redux storeから効率的にデータを取得できます。LRUアルゴリズムにより、メモリ使用量を制限しながら最近アクセスされたデータを優先的に保持し、古いデータを自動的に削除します。Redux middleware として実装することで、action dispatching時の自動キャッシュ機能、TTL(Time To Live)による期限切れ管理、キャッシュ無効化戦略などを提供します。React アプリケーションでの使用において、コンポーネントの再レンダリング最適化、API データの重複フェッチ防止、ユーザー体験の向上に大きく貢献します。

メリット・デメリット

メリット

  • パフォーマンス向上: 不要なAPIコールと再レンダリングを削減
  • Redux統合: 既存のRedux architecture への自然な統合
  • メモリ効率: LRUアルゴリズムによる自動メモリ管理
  • 開発者体験: 透明性の高いキャッシュ機能
  • 柔軟な設定: TTL、サイズ制限、無効化戦略のカスタマイズ
  • TypeScript対応: 型安全なキャッシュ実装
  • デバッグ支援: Redux DevToolsでのキャッシュ状態可視化

デメリット

  • 複雑性: 状態管理とキャッシュロジックの複雑化
  • メモリ使用量: インメモリキャッシュによる追加メモリ消費
  • デバッグ困難: キャッシュ関連のバグの発見・修正が困難
  • 一貫性の課題: データの一貫性管理の複雑化
  • 学習コスト: Redux とキャッシュ概念の両方の理解が必要
  • オーバーエンジニアリング: 小規模アプリケーションでは過剰な場合がある

主要リンク

書き方の例

Redux Middlewareとしての実装

import { LRUCache } from 'lru-cache'
import { createSlice, configureStore } from '@reduxjs/toolkit'

// LRUキャッシュの設定
const cache = new LRUCache({
  max: 1000,
  ttl: 1000 * 60 * 5 // 5分
})

// キャッシュミドルウェア
const cacheMiddleware = (store) => (next) => (action) => {
  // キャッシュ対象のアクションを判定
  if (action.type.endsWith('/fulfilled') && action.meta?.requestId) {
    const cacheKey = `${action.type}-${JSON.stringify(action.meta.arg)}`
    cache.set(cacheKey, action.payload)
  }

  // キャッシュからの取得を試行
  if (action.type.endsWith('/pending')) {
    const cacheKey = `${action.type.replace('/pending', '/fulfilled')}-${JSON.stringify(action.meta.arg)}`
    const cachedData = cache.get(cacheKey)
    
    if (cachedData) {
      // キャッシュヒット時は fulfilled アクションを直接ディスパッチ
      return next({
        type: action.type.replace('/pending', '/fulfilled'),
        payload: cachedData,
        meta: { ...action.meta, cached: true }
      })
    }
  }

  return next(action)
}

// Reduxストアの設定
const store = configureStore({
  reducer: {
    api: apiSlice.reducer
  },
  middleware: (getDefaultMiddleware) =>
    getDefaultMiddleware().concat(cacheMiddleware)
})

Redux Sliceでのキャッシュ管理

import { createSlice, createAsyncThunk } from '@reduxjs/toolkit'
import { LRUCache } from 'lru-cache'

// グローバルキャッシュ
const dataCache = new LRUCache({
  max: 500,
  ttl: 1000 * 60 * 10 // 10分
})

// 非同期アクションでキャッシュ統合
export const fetchUserData = createAsyncThunk(
  'users/fetchById',
  async (userId, { rejectWithValue }) => {
    const cacheKey = `user-${userId}`
    
    // キャッシュから確認
    const cached = dataCache.get(cacheKey)
    if (cached) {
      return { ...cached, fromCache: true }
    }

    try {
      const response = await fetch(`/api/users/${userId}`)
      const userData = await response.json()
      
      // キャッシュに保存
      dataCache.set(cacheKey, userData)
      return userData
    } catch (error) {
      return rejectWithValue(error.message)
    }
  }
)

// Reduxスライス
const usersSlice = createSlice({
  name: 'users',
  initialState: {
    entities: {},
    loading: false,
    error: null,
    cacheStats: {
      hits: 0,
      misses: 0
    }
  },
  reducers: {
    clearUserCache: (state, action) => {
      if (action.payload) {
        dataCache.delete(`user-${action.payload}`)
      } else {
        dataCache.clear()
      }
    },
    updateCacheStats: (state) => {
      state.cacheStats = {
        size: dataCache.size,
        calculatedSize: dataCache.calculatedSize
      }
    }
  },
  extraReducers: (builder) => {
    builder
      .addCase(fetchUserData.fulfilled, (state, action) => {
        state.entities[action.meta.arg] = action.payload
        state.loading = false
        
        // キャッシュ統計の更新
        if (action.payload.fromCache) {
          state.cacheStats.hits++
        } else {
          state.cacheStats.misses++
        }
      })
  }
})

export const { clearUserCache, updateCacheStats } = usersSlice.actions
export default usersSlice.reducer

ReselectとLRUキャッシュの組み合わせ

import { createSelector } from 'reselect'
import { LRUCache } from 'lru-cache'

// 計算結果のキャッシュ
const computationCache = new LRUCache({
  max: 200,
  ttl: 1000 * 60 * 30 // 30分
})

// ベースセレクター
const selectUsers = (state) => state.users.entities
const selectProducts = (state) => state.products.entities
const selectOrders = (state) => state.orders.entities

// キャッシュ付きの複雑な計算セレクター
export const selectUserOrderSummary = createSelector(
  [selectUsers, selectProducts, selectOrders],
  (users, products, orders) => {
    const cacheKey = `summary-${JSON.stringify({
      userCount: Object.keys(users).length,
      productCount: Object.keys(products).length,
      orderCount: Object.keys(orders).length
    })}`
    
    // キャッシュから確認
    const cached = computationCache.get(cacheKey)
    if (cached) {
      return cached
    }

    // 重い計算処理
    const summary = Object.values(orders).reduce((acc, order) => {
      const user = users[order.userId]
      const orderTotal = order.items.reduce((total, item) => {
        const product = products[item.productId]
        return total + (product?.price || 0) * item.quantity
      }, 0)

      acc.totalRevenue += orderTotal
      acc.ordersByUser[user?.name || 'Unknown'] = 
        (acc.ordersByUser[user?.name || 'Unknown'] || 0) + 1

      return acc
    }, {
      totalRevenue: 0,
      ordersByUser: {},
      computedAt: Date.now()
    })

    // キャッシュに保存
    computationCache.set(cacheKey, summary)
    return summary
  }
)

Reactコンポーネントでの使用

import React, { useEffect } from 'react'
import { useSelector, useDispatch } from 'react-redux'
import { fetchUserData, clearUserCache } from './userSlice'

// キャッシュ統計表示コンポーネント
const CacheStats = () => {
  const cacheStats = useSelector(state => state.users.cacheStats)
  const dispatch = useDispatch()

  useEffect(() => {
    const interval = setInterval(() => {
      dispatch(updateCacheStats())
    }, 5000) // 5秒ごとに更新

    return () => clearInterval(interval)
  }, [dispatch])

  return (
    <div className="cache-stats">
      <h3>キャッシュ統計</h3>
      <p>ヒット数: {cacheStats.hits}</p>
      <p>ミス数: {cacheStats.misses}</p>
      <p>キャッシュサイズ: {cacheStats.size}</p>
      <button onClick={() => dispatch(clearUserCache())}>
        キャッシュクリア
      </button>
    </div>
  )
}

// ユーザーデータ表示コンポーネント
const UserProfile = ({ userId }) => {
  const dispatch = useDispatch()
  const user = useSelector(state => state.users.entities[userId])
  const loading = useSelector(state => state.users.loading)

  useEffect(() => {
    if (!user) {
      dispatch(fetchUserData(userId))
    }
  }, [dispatch, userId, user])

  if (loading) return <div>読み込み中...</div>
  if (!user) return <div>ユーザーが見つかりません</div>

  return (
    <div className="user-profile">
      <h2>{user.name}</h2>
      <p>Email: {user.email}</p>
      {user.fromCache && (
        <span className="cache-indicator">キャッシュから取得</span>
      )}
    </div>
  )
}

RTK Queryとの統合

import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'
import { LRUCache } from 'lru-cache'

// カスタムキャッシュ実装
const customCache = new LRUCache({
  max: 1000,
  ttl: 1000 * 60 * 15 // 15分
})

// RTK Query API定義
export const apiSlice = createApi({
  reducerPath: 'api',
  baseQuery: fetchBaseQuery({
    baseUrl: '/api/',
    // カスタムキャッシュロジックの追加
    prepareHeaders: (headers, { endpoint, arg }) => {
      const cacheKey = `${endpoint}-${JSON.stringify(arg)}`
      const cached = customCache.get(cacheKey)
      
      if (cached) {
        headers.set('X-From-Cache', 'true')
      }
      
      return headers
    }
  }),
  tagTypes: ['User', 'Post'],
  endpoints: (builder) => ({
    getUser: builder.query({
      query: (id) => `users/${id}`,
      providesTags: ['User'],
      // カスタムキャッシュ統合
      transformResponse: (response, meta, arg) => {
        const cacheKey = `getUser-${arg}`
        customCache.set(cacheKey, response)
        return response
      }
    }),
    getPosts: builder.query({
      query: () => 'posts',
      providesTags: ['Post'],
      // 選択的キャッシュ無効化
      invalidatesTags: (result, error, arg) => {
        if (error) return []
        return ['Post']
      }
    })
  })
})

export const { useGetUserQuery, useGetPostsQuery } = apiSlice

高度なキャッシュ戦略

// 複数レベルキャッシュシステム
class MultiLevelCache {
  constructor() {
    // L1: 高速アクセス(小容量)
    this.l1Cache = new LRUCache({ max: 100, ttl: 1000 * 60 })
    
    // L2: 通常アクセス(中容量)
    this.l2Cache = new LRUCache({ max: 1000, ttl: 1000 * 60 * 10 })
    
    // L3: 長期保存(大容量)
    this.l3Cache = new LRUCache({ max: 5000, ttl: 1000 * 60 * 60 })
  }

  get(key) {
    // L1から確認
    let value = this.l1Cache.get(key)
    if (value) {
      return { value, level: 'L1' }
    }

    // L2から確認
    value = this.l2Cache.get(key)
    if (value) {
      // L1に昇格
      this.l1Cache.set(key, value)
      return { value, level: 'L2' }
    }

    // L3から確認
    value = this.l3Cache.get(key)
    if (value) {
      // L2に昇格
      this.l2Cache.set(key, value)
      return { value, level: 'L3' }
    }

    return null
  }

  set(key, value, priority = 'normal') {
    switch (priority) {
      case 'high':
        this.l1Cache.set(key, value)
        this.l2Cache.set(key, value)
        break
      case 'normal':
        this.l2Cache.set(key, value)
        break
      case 'low':
        this.l3Cache.set(key, value)
        break
    }
  }

  getStats() {
    return {
      l1: { size: this.l1Cache.size },
      l2: { size: this.l2Cache.size },
      l3: { size: this.l3Cache.size }
    }
  }
}

// Redux middleware で使用
const multiLevelCache = new MultiLevelCache()

const advancedCacheMiddleware = (store) => (next) => (action) => {
  // 優先度に基づいたキャッシュ戦略
  if (action.type.includes('user/fetch')) {
    const cached = multiLevelCache.get(action.meta.arg.userId)
    if (cached) {
      console.log(`Cache hit from ${cached.level}`)
      // キャッシュからのデータでアクションを変更
    }
  }

  const result = next(action)

  // レスポンスの優先度判定とキャッシュ
  if (action.type.endsWith('/fulfilled')) {
    const priority = action.meta.critical ? 'high' : 'normal'
    multiLevelCache.set(action.meta.arg, action.payload, priority)
  }

  return result
}