Add 'packages/cache/' from commit 'aea408b2f7bda86f28b7ea8c8f8d2b43f47915e7'

git-subtree-dir: packages/cache
git-subtree-mainline: 24d8c0515d
git-subtree-split: aea408b2f7
This commit is contained in:
Tobe O 2020-02-15 18:29:01 -05:00
commit e271a0eafd
21 changed files with 764 additions and 0 deletions

63
packages/cache/.gitignore vendored Normal file
View file

@ -0,0 +1,63 @@
# See https://www.dartlang.org/tools/private-files.html
# Files and directories created by pub
.packages
.pub/
build/
# If you're building an application, you may want to check-in your pubspec.lock
pubspec.lock
# Directory created by dartdoc
# If you don't generate documentation locally you can remove this line.
doc/api/
### JetBrains template
# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and Webstorm
# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
# User-specific stuff:
.idea/**/workspace.xml
.idea/**/tasks.xml
.idea/dictionaries
# Sensitive or high-churn files:
.idea/**/dataSources/
.idea/**/dataSources.ids
.idea/**/dataSources.xml
.idea/**/dataSources.local.xml
.idea/**/sqlDataSources.xml
.idea/**/dynamic.xml
.idea/**/uiDesigner.xml
# Gradle:
.idea/**/gradle.xml
.idea/**/libraries
# CMake
cmake-build-debug/
# Mongo Explorer plugin:
.idea/**/mongoSettings.xml
## File-based project format:
*.iws
## Plugin-specific files:
# IntelliJ
out/
# mpeltonen/sbt-idea plugin
.idea_modules/
# JIRA plugin
atlassian-ide-plugin.xml
# Cursive Clojure plugin
.idea/replstate.xml
# Crashlytics plugin (for Android Studio and IntelliJ)
com_crashlytics_export_strings.xml
crashlytics.properties
crashlytics-build.properties
fabric.properties
.dart_tool

14
packages/cache/.idea/cache.iml vendored Normal file
View file

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="JAVA_MODULE" version="4">
<component name="NewModuleRootManager" inherit-compiler-output="true">
<exclude-output />
<content url="file://$MODULE_DIR$">
<excludeFolder url="file://$MODULE_DIR$/.pub" />
<excludeFolder url="file://$MODULE_DIR$/build" />
</content>
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
<orderEntry type="library" name="Dart SDK" level="project" />
<orderEntry type="library" name="Dart Packages" level="project" />
</component>
</module>

8
packages/cache/.idea/modules.xml vendored Normal file
View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/cache.iml" filepath="$PROJECT_DIR$/.idea/cache.iml" />
</modules>
</component>
</project>

View file

@ -0,0 +1,7 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="cache_service.dart" type="DartCommandLineRunConfigurationType" factoryName="Dart Command Line Application" singleton="true" nameIsGenerated="true">
<option name="filePath" value="$PROJECT_DIR$/example/cache_service.dart" />
<option name="workingDirectory" value="$PROJECT_DIR$" />
<method />
</configuration>
</component>

View file

@ -0,0 +1,7 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="main.dart" type="DartCommandLineRunConfigurationType" factoryName="Dart Command Line Application" singleton="true" nameIsGenerated="true">
<option name="filePath" value="$PROJECT_DIR$/example/main.dart" />
<option name="workingDirectory" value="$PROJECT_DIR$" />
<method />
</configuration>
</component>

View file

@ -0,0 +1,8 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="sends 304 on if-modified-since in cache_test.dart" type="DartTestRunConfigurationType" factoryName="Dart Test" singleton="true" nameIsGenerated="true">
<option name="filePath" value="$PROJECT_DIR$/test/cache_test.dart" />
<option name="scope" value="GROUP_OR_TEST_BY_NAME" />
<option name="testName" value="sends 304 on if-modified-since" />
<method />
</configuration>
</component>

View file

@ -0,0 +1,6 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="tests in cache_test.dart" type="DartTestRunConfigurationType" factoryName="Dart Test" singleton="true" nameIsGenerated="true">
<option name="filePath" value="$PROJECT_DIR$/test/cache_test.dart" />
<method />
</configuration>
</component>

6
packages/cache/.idea/vcs.xml vendored Normal file
View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>

4
packages/cache/.travis.yml vendored Normal file
View file

@ -0,0 +1,4 @@
language: dart
dart:
- stable
- dev

6
packages/cache/CHANGELOG.md vendored Normal file
View file

@ -0,0 +1,6 @@
# 2.0.1
* Add `ignoreQueryAndFragment` to `ResponseCache`.
* Rename `CacheService.ignoreQuery` to `ignoreParams`.
# 1.0.0
* First version

21
packages/cache/LICENSE vendored Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2017 The Angel Framework
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

92
packages/cache/README.md vendored Normal file
View file

@ -0,0 +1,92 @@
# cache
[![Pub](https://img.shields.io/pub/v/angel_cache.svg)](https://pub.dartlang.org/packages/angel_cache)
[![build status](https://travis-ci.org/angel-dart/cache.svg)](https://travis-ci.org/angel-dart/cache)
Support for server-side caching in [Angel](https://angel-dart.github.io).
## `CacheService`
A `Service` class that caches data from one service, storing it in another.
An imaginable use case is storing results from MongoDB or another database in
MemcacheD/Redis.
## `cacheSerializationResults`
A middleware that enables the caching of response serialization.
This can improve the performance of sending objects that are complex to serialize.
You can pass a [shouldCache] callback to determine which values should be cached.
```dart
main() async {
var app = new Angel()..lazyParseBodies = true;
app.use(
'/api/todos',
new CacheService(
database: new AnonymousService(
index: ([params]) {
print('Fetched directly from the underlying service at ${new DateTime.now()}!');
return ['foo', 'bar', 'baz'];
},
read: (id, [params]) {
return {id: '$id at ${new DateTime.now()}'};
}
),
),
);
}
```
## `ResponseCache`
A flexible response cache for Angel.
Use this to improve real and perceived response of Web applications,
as well as to memoize expensive responses.
Supports the `If-Modified-Since` header, as well as storing the contents of
response buffers in memory.
To initialize a simple cache:
```dart
Future configureServer(Angel app) async {
// Simple instance.
var cache = new ResponseCache();
// You can also pass an invalidation timeout.
var cache = new ResponseCache(timeout: const Duration(days: 2));
// Close the cache when the application closes.
app.shutdownHooks.add((_) => cache.close());
// Use `patterns` to specify which resources should be cached.
cache.patterns.addAll([
'robots.txt',
new RegExp(r'\.(png|jpg|gif|txt)$'),
new Glob('public/**/*'),
]);
// REQUIRED: The middleware that serves cached responses
app.use(cache.handleRequest);
// REQUIRED: The response finalizer that saves responses to the cache
app.responseFinalizers.add(cache.responseFinalizer);
}
```
### Purging the Cache
Call `invalidate` to remove a resource from a `ResponseCache`.
Some servers expect a reverse proxy or caching layer to support `PURGE` requests.
If this is your case, make sure to include some sort of validation (maybe IP-based)
to ensure no arbitrary attacker can hack your cache:
```dart
Future configureServer(Angel app) async {
app.addRoute('PURGE', '*', (req, res) {
if (req.ip != '127.0.0.1')
throw new AngelHttpException.forbidden();
return cache.purge(req.uri.path);
});
}
```

3
packages/cache/analysis_options.yaml vendored Normal file
View file

@ -0,0 +1,3 @@
analyzer:
strong-mode:
implicit-casts: false

View file

@ -0,0 +1,24 @@
import 'package:angel_cache/angel_cache.dart';
import 'package:angel_framework/angel_framework.dart';
main() async {
var app = new Angel();
app.use(
'/api/todos',
new CacheService(
cache: new MapService(),
database: new AnonymousService(index: ([params]) {
print(
'Fetched directly from the underlying service at ${new DateTime.now()}!');
return ['foo', 'bar', 'baz'];
}, read: (id, [params]) {
return {id: '$id at ${new DateTime.now()}'};
}),
),
);
var http = new AngelHttp(app);
var server = await http.startServer('127.0.0.1', 3000);
print('Listening at http://${server.address.address}:${server.port}');
}

35
packages/cache/example/main.dart vendored Normal file
View file

@ -0,0 +1,35 @@
import 'package:angel_cache/angel_cache.dart';
import 'package:angel_framework/angel_framework.dart';
import 'package:glob/glob.dart';
main() async {
var app = new Angel();
// Cache a glob
var cache = new ResponseCache()
..patterns.addAll([
new Glob('/*.txt'),
]);
// Handle `if-modified-since` header, and also send cached content
app.fallback(cache.handleRequest);
// A simple handler that returns a different result every time.
app.get('/date.txt',
(req, res) => res.write(new DateTime.now().toIso8601String()));
// Support purging the cache.
app.addRoute('PURGE', '*', (req, res) {
if (req.ip != '127.0.0.1') throw new AngelHttpException.forbidden();
cache.purge(req.uri.path);
print('Purged ${req.uri.path}');
});
// The response finalizer that actually saves the content
app.responseFinalizers.add(cache.responseFinalizer);
var http = new AngelHttp(app);
var server = await http.startServer('127.0.0.1', 3000);
print('Listening at http://${server.address.address}:${server.port}');
}

3
packages/cache/lib/angel_cache.dart vendored Normal file
View file

@ -0,0 +1,3 @@
export 'src/cache.dart';
export 'src/cache_service.dart';
export 'src/serializer.dart';

169
packages/cache/lib/src/cache.dart vendored Normal file
View file

@ -0,0 +1,169 @@
import 'dart:async';
import 'dart:io' show HttpDate;
import 'package:angel_framework/angel_framework.dart';
import 'package:pool/pool.dart';
/// A flexible response cache for Angel.
///
/// Use this to improve real and perceived response of Web applications,
/// as well as to memoize expensive responses.
class ResponseCache {
/// A set of [Patterns] for which responses will be cached.
///
/// For example, you can pass a `Glob` matching `**/*.png` files to catch all PNG images.
final List<Pattern> patterns = [];
/// An optional timeout, after which a given response will be removed from the cache, and the contents refreshed.
final Duration timeout;
final Map<String, _CachedResponse> _cache = {};
final Map<String, Pool> _writeLocks = {};
/// If `true` (default: `false`), then caching of results will discard URI query parameters and fragments.
final bool ignoreQueryAndFragment;
ResponseCache({this.timeout, this.ignoreQueryAndFragment: false});
/// Closes all internal write-locks, and closes the cache.
Future close() async {
_writeLocks.forEach((_, p) => p.close());
}
/// Removes an entry from the response cache.
void purge(String path) => _cache.remove(path);
/// A middleware that handles requests with an `If-Modified-Since` header.
///
/// This prevents the server from even having to access the cache, and plays very well with static assets.
Future<bool> ifModifiedSince(RequestContext req, ResponseContext res) async {
if (req.method != 'GET' && req.method != 'HEAD') return true;
if (req.headers.ifModifiedSince != null) {
var modifiedSince = req.headers.ifModifiedSince;
// Check if there is a cache entry.
for (var pattern in patterns) {
if (pattern.allMatches(_getEffectivePath(req)).isNotEmpty &&
_cache.containsKey(_getEffectivePath(req))) {
var response = _cache[_getEffectivePath(req)];
//print('timestamp ${response.timestamp} vs since ${modifiedSince}');
if (response.timestamp.compareTo(modifiedSince) <= 0) {
if (timeout != null) {
// If the cache timeout has been met, don't send the cached response.
if (new DateTime.now().toUtc().difference(response.timestamp) >=
timeout) return true;
}
res.statusCode = 304;
return false;
}
}
}
}
return true;
}
String _getEffectivePath(RequestContext req) =>
ignoreQueryAndFragment == true ? req.uri.path : req.uri.toString();
/// Serves content from the cache, if applicable.
Future<bool> handleRequest(RequestContext req, ResponseContext res) async {
if (!await ifModifiedSince(req, res)) return false;
if (req.method != 'GET' && req.method != 'HEAD') return true;
if (!res.isOpen) return true;
// Check if there is a cache entry.
//
// If `if-modified-since` is present, this check has already been performed.
if (req.headers.ifModifiedSince == null) {
for (var pattern in patterns) {
if (pattern.allMatches(_getEffectivePath(req)).isNotEmpty) {
var now = new DateTime.now().toUtc();
if (_cache.containsKey(_getEffectivePath(req))) {
var response = _cache[_getEffectivePath(req)];
if (timeout != null) {
// If the cache timeout has been met, don't send the cached response.
if (now.difference(response.timestamp) >= timeout) return true;
}
_setCachedHeaders(response.timestamp, req, res);
res
..headers.addAll(response.headers)
..add(response.body)
..close();
return false;
} else {
_setCachedHeaders(now, req, res);
}
}
}
}
return true;
}
/// A response finalizer that saves responses to the cache.
Future<bool> responseFinalizer(
RequestContext req, ResponseContext res) async {
if (res.statusCode == 304) return true;
if (req.method != 'GET' && req.method != 'HEAD') return true;
// Check if there is a cache entry.
for (var pattern in patterns) {
if (pattern.allMatches(_getEffectivePath(req)).isNotEmpty) {
var now = new DateTime.now().toUtc();
// Invalidate the response, if need be.
if (_cache.containsKey(_getEffectivePath(req))) {
// If there is no timeout, don't invalidate.
if (timeout == null) return true;
// Otherwise, don't invalidate unless the timeout has been exceeded.
var response = _cache[_getEffectivePath(req)];
if (now.difference(response.timestamp) < timeout) return true;
// If the cache entry should be invalidated, then invalidate it.
purge(_getEffectivePath(req));
}
// Save the response.
var writeLock =
_writeLocks.putIfAbsent(_getEffectivePath(req), () => new Pool(1));
await writeLock.withResource(() {
_cache[_getEffectivePath(req)] = new _CachedResponse(
new Map.from(res.headers), res.buffer.toBytes(), now);
});
_setCachedHeaders(now, req, res);
}
}
return true;
}
void _setCachedHeaders(
DateTime modified, RequestContext req, ResponseContext res) {
var privacy = 'public';
res.headers
..['cache-control'] = '$privacy, max-age=${timeout?.inSeconds ?? 86400}'
..['last-modified'] = HttpDate.format(modified);
if (timeout != null) {
var expiry = new DateTime.now().add(timeout);
res.headers['expires'] = HttpDate.format(expiry);
}
}
}
class _CachedResponse {
final Map<String, String> headers;
final List<int> body;
final DateTime timestamp;
_CachedResponse(this.headers, this.body, this.timestamp);
}

View file

@ -0,0 +1,138 @@
import 'dart:async';
import 'package:collection/collection.dart';
import 'package:angel_framework/angel_framework.dart';
import 'package:meta/meta.dart';
/// An Angel [Service] that caches data from another service.
///
/// This is useful for applications of scale, where network latency
/// can have real implications on application performance.
class CacheService<Id, Data> extends Service<Id, Data> {
/// The underlying [Service] that represents the original data store.
final Service<Id, Data> database;
/// The [Service] used to interface with a caching layer.
///
/// If not provided, this defaults to a [MapService].
final Service<Id, Data> cache;
/// If `true` (default: `false`), then result caching will discard parameters passed to service methods.
///
/// If you want to return a cached result more-often-than-not, you may want to enable this.
final bool ignoreParams;
final Duration timeout;
final Map<Id, _CachedItem<Data>> _cache = {};
_CachedItem<List<Data>> _indexed;
CacheService(
{@required this.database,
@required this.cache,
this.ignoreParams: false,
this.timeout}) {
assert(database != null);
}
Future<T> _getCached<T>(
Map<String, dynamic> params,
_CachedItem get(),
FutureOr<T> getFresh(),
FutureOr<T> getCached(),
FutureOr<T> save(T data, DateTime now)) async {
var cached = get();
var now = new DateTime.now().toUtc();
if (cached != null) {
// If the entry has expired, don't send from the cache
var expired =
timeout != null && now.difference(cached.timestamp) >= timeout;
if (timeout == null || !expired) {
// Read from the cache if necessary
var queryEqual = ignoreParams == true ||
(params != null &&
cached.params != null &&
const MapEquality().equals(
params['query'] as Map, cached.params['query'] as Map));
if (queryEqual) {
return await getCached();
}
}
}
// If we haven't fetched from the cache by this point,
// let's fetch from the database.
var data = await getFresh();
await save(data, now);
return data;
}
@override
Future<List<Data>> index([Map<String, dynamic> params]) {
return _getCached(
params,
() => _indexed,
() => database.index(params),
() => _indexed?.data ?? [],
(data, now) async {
_indexed = new _CachedItem(params, now, data);
return data;
},
);
}
@override
Future<Data> read(Id id, [Map<String, dynamic> params]) async {
return _getCached<Data>(
params,
() => _cache[id],
() => database.read(id, params),
() => cache.read(id),
(data, now) async {
_cache[id] = new _CachedItem(params, now, data);
return await cache.modify(id, data);
},
);
}
@override
Future<Data> create(data, [Map<String, dynamic> params]) {
_indexed = null;
return database.create(data, params);
}
@override
Future<Data> modify(Id id, Data data, [Map<String, dynamic> params]) {
_indexed = null;
_cache.remove(id);
return database.modify(id, data, params);
}
@override
Future<Data> update(Id id, Data data, [Map<String, dynamic> params]) {
_indexed = null;
_cache.remove(id);
return database.modify(id, data, params);
}
@override
Future<Data> remove(Id id, [Map<String, dynamic> params]) {
_indexed = null;
_cache.remove(id);
return database.remove(id, params);
}
}
class _CachedItem<Data> {
final params;
final DateTime timestamp;
final Data data;
_CachedItem(this.params, this.timestamp, [this.data]);
@override
String toString() {
return '$timestamp:$params:$data';
}
}

26
packages/cache/lib/src/serializer.dart vendored Normal file
View file

@ -0,0 +1,26 @@
import 'dart:async';
import 'package:angel_framework/angel_framework.dart';
/// A middleware that enables the caching of response serialization.
///
/// This can improve the performance of sending objects that are complex to serialize.
///
/// You can pass a [shouldCache] callback to determine which values should be cached.
RequestHandler cacheSerializationResults(
{Duration timeout,
FutureOr<bool> Function(RequestContext, ResponseContext, Object)
shouldCache}) {
return (RequestContext req, ResponseContext res) async {
var oldSerializer = res.serializer;
var cache = <dynamic, String>{};
res.serializer = (value) {
if (shouldCache == null) {
return cache.putIfAbsent(value, () => oldSerializer(value));
}
return oldSerializer(value);
};
return true;
};
}

17
packages/cache/pubspec.yaml vendored Normal file
View file

@ -0,0 +1,17 @@
name: angel_cache
version: 2.0.1
homepage: https://github.com/angel-dart/cache
description: Support for server-side caching in Angel.
author: Tobe O <thosakwe@gmail.com>
environment:
sdk: ">=2.0.0-dev <3.0.0"
dependencies:
angel_framework: ^2.0.0-alpha
collection: ^1.0.0
meta: ^1.0.0
pool: ^1.0.0
dev_dependencies:
angel_test: ^2.0.0-alpha
glob: ^1.0.0
http: any
test: ^1.0.0

107
packages/cache/test/cache_test.dart vendored Normal file
View file

@ -0,0 +1,107 @@
import 'dart:async';
import 'dart:io';
import 'package:angel_cache/angel_cache.dart';
import 'package:angel_framework/angel_framework.dart';
import 'package:angel_test/angel_test.dart';
import 'package:http/http.dart' as http;
import 'package:glob/glob.dart';
import 'package:test/test.dart';
main() async {
group('no timeout', () {
TestClient client;
DateTime lastModified;
http.Response response1, response2;
setUp(() async {
var app = new Angel();
var cache = new ResponseCache()
..patterns.addAll([
new Glob('/*.txt'),
]);
app.fallback(cache.handleRequest);
app.get('/date.txt', (req, res) {
res
..useBuffer()
..write(new DateTime.now().toIso8601String());
});
app.addRoute('PURGE', '*', (req, res) {
cache.purge(req.uri.path);
print('Purged ${req.uri.path}');
});
app.responseFinalizers.add(cache.responseFinalizer);
var oldHandler = app.errorHandler;
app.errorHandler = (e, req, res) {
if (e.error == null) return oldHandler(e, req, res);
return Zone.current.handleUncaughtError(e.error, e.stackTrace);
};
client = await connectTo(app);
response1 = await client.get('/date.txt');
response2 = await client.get('/date.txt');
print(response2.headers);
lastModified = HttpDate.parse(response2.headers['last-modified']);
print('Response 1 status: ${response1.statusCode}');
print('Response 2 status: ${response2.statusCode}');
print('Response 1 body: ${response1.body}');
print('Response 2 body: ${response2.body}');
print('Response 1 headers: ${response1.headers}');
print('Response 2 headers: ${response2.headers}');
});
tearDown(() => client.close());
test('saves content', () async {
expect(response1.body, response2.body);
});
test('saves headers', () async {
response1.headers.forEach((k, v) {
expect(response2.headers, containsPair(k, v));
});
});
test('first response is normal', () {
expect(response1.statusCode, 200);
});
test('sends last-modified', () {
expect(response2.headers.keys, contains('last-modified'));
});
test('invalidate', () async {
await client.sendUnstreamed('PURGE', '/date.txt', {});
var response = await client.get('/date.txt');
print('Response after invalidation: ${response.body}');
expect(response.body, isNot(response1.body));
});
test('sends 304 on if-modified-since', () async {
var headers = {
'if-modified-since':
HttpDate.format(lastModified.add(const Duration(days: 1)))
};
var response = await client.get('/date.txt', headers: headers);
print('Sending headers: $headers');
print('Response (${response.statusCode}): ${response.headers}');
expect(response.statusCode, 304);
});
test('last-modified in the past', () async {
var response = await client.get('/date.txt', headers: {
'if-modified-since':
HttpDate.format(lastModified.subtract(const Duration(days: 10)))
});
print('Response: ${response.body}');
expect(response.statusCode, 200);
expect(response.body, isNot(response1.body));
});
});
group('with timeout', () {});
}