Skip to content

Instantly share code, notes, and snippets.

@CoderNamedHendrick
Last active May 24, 2024 16:58
Show Gist options
  • Save CoderNamedHendrick/dde176c44858b92667dfa6f4cb01fa4e to your computer and use it in GitHub Desktop.
Save CoderNamedHendrick/dde176c44858b92667dfa6f4cb01fa4e to your computer and use it in GitHub Desktop.
Cache implementation. Provides basic building blocks to implement the popular caching strategies: read-aside, read-through and write-through. Also extendible enough to implement other strategies on.
import 'dart:convert';
final class CacheItem {
final String key;
final Object data;
final DateTime expiry;
const CacheItem._(this.key, this.data, this.expiry);
/// This factory constructor is used for caching data that shouldn't persist
/// so they're set to expired just as they're created by setting the expiry
/// to the current time.
factory CacheItem.ephemeralStore({
required String key,
required Object data,
}) {
return CacheItem._(key, data, DateTime.now());
}
/// This factory constructor is used to create cache items that will persist
/// for the specified duration as soon as they're created.
factory CacheItem.persistentStore({
required String key,
required Object data,
required Duration duration,
}) {
return CacheItem._(key, data, DateTime.now().add(duration));
}
bool get isExpired => DateTime.now().isAfter(expiry);
bool get isValid => !isExpired;
bool get isInvalid => !isValid;
String toCacheEntryString() {
return jsonEncode({
'expiry': expiry.toIso8601String(),
'data': data,
});
}
static CacheItem fromCacheEntryString(String entry, {required String key}) {
final json = jsonDecode(entry);
return CacheItem._(
key,
json['data'],
DateTime.parse(json['expiry']),
);
}
CacheItem copyWith({
String? key,
Object? data,
DateTime? expiry,
Duration? persistenceDuration,
}) {
return CacheItem._(
key ?? this.key,
data ?? this.data,
switch ((expiry, persistenceDuration)) {
(final expiry?, final persistenceDuration?) =>
expiry.add(persistenceDuration),
(final expiry?, null) => expiry,
(null, final persistenceDuration?) =>
DateTime.now().add(persistenceDuration),
(null, null) => this.expiry,
},
);
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is CacheItem &&
other.key == key &&
other.data == data &&
other.expiry == expiry;
}
@override
int get hashCode => key.hashCode ^ data.hashCode ^ expiry.hashCode;
}
abstract interface class CacheStore {
Future<int> get cacheVersion;
Future<void> updateCacheVersion(int version);
Future<void> initialiseStore();
Future<void> saveCacheItem(CacheItem item);
Future<CacheItem?> getCacheItem(String key);
Future<void> invalidateCacheItem(String key);
Future<void> invalidateCache();
bool containsKey(String key);
}
import 'package:test/expect.dart';
import 'package:test/scaffolding.dart';
import '../bin/cache_manager/cache_manager.dart';
void main() {
group('Cache test suite', () {
late CacheManager cache;
setUp(() {
CacheManager.init(store: InMemoryCacheStore());
cache = CacheManager.instance;
});
test('ephemeral cache items expire immediately', () async {
final item = CacheItem.ephemeralStore(
key: 'test-key-1',
data: {'name': 'John Doe'},
);
cache.set(item);
final cachedItem = await cache.get('test-key-1');
expect(cachedItem?.isExpired, true);
expect(cachedItem?.data, {'name': 'John Doe'});
});
test('persistent cache items last as long as their specified duration',
() async {
final item = CacheItem.persistentStore(
key: 'test-key-2',
data: {'message': 'Hello world'},
duration: Duration(seconds: 3),
);
cache.set(item);
final cachedItem = await cache.get('test-key-2');
expect(cachedItem?.isExpired, false);
expect(cachedItem?.data, {'message': 'Hello world'});
await Future.delayed(Duration(seconds: 3));
expect(cachedItem?.isExpired, true);
expect(cachedItem?.data, {'message': 'Hello world'});
});
test('override expiry on persistent cache item', () async {
final item = CacheItem.persistentStore(
key: 'test-key-3',
data: {'data': 'look at me'},
duration: Duration(seconds: 40),
);
cache.set(item);
final cachedItem = await cache.get('test-key-3');
expect(cachedItem?.isExpired, false);
expect(cachedItem?.data, {'data': 'look at me'});
cache.set(item.copyWith(persistenceDuration: Duration.zero));
final updatedCachedItem = await cache.get('test-key-3');
expect(updatedCachedItem?.isExpired, true);
expect(updatedCachedItem?.data, {'data': 'look at me'});
});
});
}
import 'package:hive_flutter/hive_flutter.dart';
import 'cache_item.dart';
import 'cache_store.dart';
// For flutter apps replace, hive with hive
final class HiveCacheStore implements CacheStore {
late final Box<String> _store;
@override
Future<int> get cacheVersion async {
final version = _store.get('version');
if (version == null) return -1;
return int.parse(version);
}
@override
Future<void> updateCacheVersion(int version) async {
return await _store.put('version', version.toString());
}
@override
bool containsKey(String key) {
return _store.containsKey(key);
}
@override
Future<CacheItem?> getCacheItem(String key) async {
final item = _store.get(key);
if (item == null) return null;
return CacheItem.fromCacheEntryString(item, key: key);
}
@override
Future<void> initialiseStore() async {
await Hive.initFlutter();
_store = await Hive.openBox('cache-store');
}
@override
Future<void> invalidateCache() async {
await _store.clear();
}
@override
Future<void> invalidateCacheItem(String key) async {
final item = await getCacheItem(key);
if (item == null) return;
return await saveCacheItem(
item.copyWith(persistenceDuration: const Duration(minutes: -5)));
}
@override
Future<void> saveCacheItem(CacheItem item) async {
return await _store.put(item.key, item.toCacheEntryString());
}
}
import 'cache_store.dart';
import 'cache_item.dart';
final class InMemoryCacheStore implements CacheStore {
InMemoryCacheStore();
late final Map<String, String> _store;
@override
Future<int> get cacheVersion async {
return int.parse(_store['version'] ?? '-1');
}
@override
Future<void> updateCacheVersion(int version) async {
_store['version'] = version.toString();
}
@override
bool containsKey(String key) {
return _store.containsKey(key);
}
@override
Future<CacheItem?> getCacheItem(String key) async {
final item = _store[key];
if (item == null) return null;
return CacheItem.fromCacheEntryString(item, key: key);
}
@override
Future<void> initialiseStore() async {
_store = {};
}
@override
Future<void> invalidateCache() async {
return _store.clear();
}
@override
Future<void> invalidateCacheItem(String key) async {
final item = await getCacheItem(key);
if (item == null) return;
return await saveCacheItem(
item.copyWith(persistenceDuration: const Duration(minutes: -5)));
}
@override
Future<void> saveCacheItem(CacheItem item) async {
_store[item.key] = item.toCacheEntryString();
}
}
import 'cache_item.dart';
import 'cache_store.dart';
inal class CacheManager {
late final CacheStore _store;
CacheManager._();
static CacheManager? _instance;
static CacheManager get instance {
if (_instance == null) {
throw ArgumentError('Cache not initialized');
}
return _instance!;
}
static Future<void> init({required CacheStore store}) async {
_instance = CacheManager._();
_instance!._store = store;
await instance._store.initialiseStore();
}
Future<int> cacheVersion() async {
return await _store.cacheVersion;
}
Future<void> updateCacheVersion(int version) async {
return await _store.updateCacheVersion(version);
}
Future<void> set(CacheItem item) async {
return await _store.saveCacheItem(item);
}
Future<CacheItem?> get(String key) async {
return await _store.getCacheItem(key);
}
bool contains(String key) {
return _store.containsKey(key);
}
Future<void> invalidateCacheItem(String key) async {
return await _store.invalidateCacheItem(key);
}
Future<bool> cacheItemExpired(String key) async {
final item = await get(key);
return item?.isExpired ?? true;
}
Future<void> invalidateCache() async {
return await _store.invalidateCache();
}
}
final class CacheManagerUtils {
static String composeKeyFromUrl(
String path, {
required String requestMethod,
Map<String, dynamic>? queryParams,
}) {
return '$requestMethod:$path${queryParams?.queryString ?? ''}';
}
}
extension on Map<String, dynamic> {
String get queryString {
final buffer = StringBuffer();
for (int i = 0; i < length; i++) {
if (i > 0) buffer.write('&');
final entry = entries.elementAt(i);
buffer.write('${entry.key}=${entry.value}');
}
return '?${buffer.toString()}';
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment