docs+feat: add Disposable interface source and usage example

feat(core,doc): unified async dispose mechanism for resource cleanup

BREAKING CHANGE:

- Added full support for asynchronous resource cleanup via a unified FutureOr<void> dispose() method in the Disposable interface.
- The Scope now provides only Future<void> dispose() for disposing all tracked resources and child scopes (sync-only dispose() was removed).
- All calls to cleanup in code and tests (scope itself, subscopes, and custom modules) now require await ...dispose().
- Documentation and all examples updated: resource management is always async and must be awaited; Disposable implementers may use both sync and async cleanup.
- Old-style, synchronous cleanup methods have been completely removed (API is now consistently async for all DI lifecycle management).
- Example and tutorial code now demonstrate async resource disposal patterns.
This commit is contained in:
Sergey Penkovsky
2025-08-01 15:07:12 +03:00
parent 61f2268d63
commit a9c95f6a89
14 changed files with 516 additions and 69 deletions

View File

@@ -79,8 +79,55 @@ final str = rootScope.resolve<String>();
// Resolve a dependency asynchronously
final result = await rootScope.resolveAsync<String>();
// Close the root scope once done
CherryPick.closeRootScope();
// Recommended: Close the root scope and release all resources
await CherryPick.closeRootScope();
// Alternatively, you may manually call dispose on any scope you manage individually
// await rootScope.dispose();
```
### Automatic resource management (`Disposable`, `dispose`)
CherryPick automatically manages the lifecycle of any object registered via DI that implements the `Disposable` interface.
**Best practice:**
Always finish your work with `await CherryPick.closeRootScope()` (for the root scope) or `await scope.closeSubScope('key')` (for subscopes).
These methods will automatically await `dispose()` on all resolved objects (e.g., singletons) that implement `Disposable`, ensuring proper and complete resource cleanup—sync or async.
Manual `await scope.dispose()` may be useful if you manually manage custom scopes.
#### Example
```dart
class MyService implements Disposable {
@override
FutureOr<void> dispose() async {
// release resources, close streams, perform async shutdown, etc.
print('MyService disposed!');
}
}
final scope = openRootScope();
scope.installModules([
ModuleImpl(),
]);
final service = scope.resolve<MyService>();
// ... use service
// Recommended completion:
await CherryPick.closeRootScope(); // will print: MyService disposed!
// Or, to close and clean up a subscope and its resources:
await scope.closeSubScope('feature');
class ModuleImpl extends Module {
@override
void builder(Scope scope) {
bind<MyService>().toProvide(() => MyService()).singleton();
}
}
```
#### Working with Subscopes

View File

@@ -0,0 +1,40 @@
import 'package:cherrypick/cherrypick.dart';
/// Ваш сервис с освобождением ресурсов
class MyService implements Disposable {
bool wasDisposed = false;
@override
void dispose() {
// Например: закрыть соединение, остановить таймер, освободить память
wasDisposed = true;
print('MyService disposed!');
}
void doSomething() => print('Doing something...');
}
void main() {
final scope = CherryPick.openRootScope();
// Регистрируем биндинг (singleton для примера)
scope.installModules([
ModuleImpl(),
]);
// Получаем зависимость
final service = scope.resolve<MyService>();
service.doSomething(); // «Doing something...»
// Освобождаем все ресурсы
scope.dispose();
print('Service wasDisposed = ${service.wasDisposed}'); // true
}
/// Пример модуля CherryPick
class ModuleImpl extends Module {
@override
void builder(Scope scope) {
bind<MyService>().toProvide(() => MyService()).singleton();
}
}

View File

@@ -21,3 +21,4 @@ export 'package:cherrypick/src/helper.dart';
export 'package:cherrypick/src/module.dart';
export 'package:cherrypick/src/scope.dart';
export 'package:cherrypick/src/logger.dart';
export 'package:cherrypick/src/disposable.dart';

View File

@@ -0,0 +1,10 @@
/// Базовый интерфейс для автоматического управления ресурсами в CherryPick.
/// Если объект реализует [Disposable], DI-контейнер вызовет [dispose] при очистке scope.
import 'dart:async';
/// Interface for resources that need to be disposed synchronously or asynchronously.
abstract class Disposable {
/// Releases all resources held by this object.
/// For sync disposables, just implement as void; for async ones, return Future.
FutureOr<void> dispose();
}

View File

@@ -95,7 +95,10 @@ class CherryPick {
/// CherryPick.closeRootScope();
/// ```
static void closeRootScope() {
_rootScope = null;
if (_rootScope != null) {
_rootScope!.dispose(); // Автоматический вызов dispose для rootScope!
_rootScope = null;
}
}
/// Globally enables cycle detection for all new [Scope]s created by CherryPick.

View File

@@ -14,6 +14,7 @@ import 'dart:collection';
import 'dart:math';
import 'package:cherrypick/src/cycle_detector.dart';
import 'package:cherrypick/src/disposable.dart';
import 'package:cherrypick/src/global_cycle_detector.dart';
import 'package:cherrypick/src/binding_resolver.dart';
import 'package:cherrypick/src/module.dart';
@@ -28,6 +29,9 @@ class Scope with CycleDetectionMixin, GlobalCycleDetectionMixin {
@override
CherryPickLogger get logger => _logger;
/// COLLECTS all resolved instances that implement [Disposable].
final Set<Disposable> _disposables = HashSet();
/// RU: Метод возвращает родительский [Scope].
///
/// ENG: The method returns the parent [Scope].
@@ -94,14 +98,15 @@ class Scope with CycleDetectionMixin, GlobalCycleDetectionMixin {
return _scopeMap[name]!;
}
/// RU: Метод закрывает дочерний (дополнительный) [Scope].
/// RU: Метод закрывает дочерний (дополнительный) [Scope] асинхронно.
///
/// ENG: The method closes child (additional) [Scope].
/// ENG: The method closes child (additional) [Scope] asynchronously.
///
/// return [Scope]
void closeSubScope(String name) {
/// return [Future<void>]
Future<void> closeSubScope(String name) async {
final childScope = _scopeMap[name];
if (childScope != null) {
await childScope.dispose(); // асинхронный вызов
// Очищаем детектор для дочернего скоупа
if (childScope.scopeId != null) {
GlobalCycleDetector.instance.removeScopeDetector(childScope.scopeId!);
@@ -175,9 +180,10 @@ class Scope with CycleDetectionMixin, GlobalCycleDetectionMixin {
///
T resolve<T>({String? named, dynamic params}) {
// Используем глобальное отслеживание, если включено
T result;
if (isGlobalCycleDetectionEnabled) {
try {
return withGlobalCycleDetection<T>(T, named, () {
result = withGlobalCycleDetection<T>(T, named, () {
return _resolveWithLocalDetection<T>(named: named, params: params);
});
} catch (e, s) {
@@ -195,7 +201,7 @@ class Scope with CycleDetectionMixin, GlobalCycleDetectionMixin {
}
} else {
try {
return _resolveWithLocalDetection<T>(named: named, params: params);
result = _resolveWithLocalDetection<T>(named: named, params: params);
} catch (e, s) {
logger.error(
formatLogMessage(
@@ -210,6 +216,8 @@ class Scope with CycleDetectionMixin, GlobalCycleDetectionMixin {
rethrow;
}
}
_trackDisposable(result);
return result;
}
/// RU: Разрешение с локальным детектором циклических зависимостей.
@@ -251,13 +259,16 @@ class Scope with CycleDetectionMixin, GlobalCycleDetectionMixin {
///
T? tryResolve<T>({String? named, dynamic params}) {
// Используем глобальное отслеживание, если включено
T? result;
if (isGlobalCycleDetectionEnabled) {
return withGlobalCycleDetection<T?>(T, named, () {
result = withGlobalCycleDetection<T?>(T, named, () {
return _tryResolveWithLocalDetection<T>(named: named, params: params);
});
} else {
return _tryResolveWithLocalDetection<T>(named: named, params: params);
result = _tryResolveWithLocalDetection<T>(named: named, params: params);
}
if (result != null) _trackDisposable(result);
return result;
}
/// RU: Попытка разрешения с локальным детектором циклических зависимостей.
@@ -295,13 +306,16 @@ class Scope with CycleDetectionMixin, GlobalCycleDetectionMixin {
///
Future<T> resolveAsync<T>({String? named, dynamic params}) async {
// Используем глобальное отслеживание, если включено
T result;
if (isGlobalCycleDetectionEnabled) {
return withGlobalCycleDetection<Future<T>>(T, named, () async {
result = await withGlobalCycleDetection<Future<T>>(T, named, () async {
return await _resolveAsyncWithLocalDetection<T>(named: named, params: params);
});
} else {
return await _resolveAsyncWithLocalDetection<T>(named: named, params: params);
result = await _resolveAsyncWithLocalDetection<T>(named: named, params: params);
}
_trackDisposable(result);
return result;
}
/// RU: Асинхронное разрешение с локальным детектором циклических зависимостей.
@@ -320,13 +334,16 @@ class Scope with CycleDetectionMixin, GlobalCycleDetectionMixin {
Future<T?> tryResolveAsync<T>({String? named, dynamic params}) async {
// Используем глобальное отслеживание, если включено
T? result;
if (isGlobalCycleDetectionEnabled) {
return withGlobalCycleDetection<Future<T?>>(T, named, () async {
result = await withGlobalCycleDetection<Future<T?>>(T, named, () async {
return await _tryResolveAsyncWithLocalDetection<T>(named: named, params: params);
});
} else {
return await _tryResolveAsyncWithLocalDetection<T>(named: named, params: params);
result = await _tryResolveAsyncWithLocalDetection<T>(named: named, params: params);
}
if (result != null) _trackDisposable(result);
return result;
}
/// RU: Асинхронная попытка разрешения с локальным детектором циклических зависимостей.
@@ -366,4 +383,27 @@ class Scope with CycleDetectionMixin, GlobalCycleDetectionMixin {
}
}
}
/// INTERNAL: Tracks Disposable objects
void _trackDisposable(Object? obj) {
if (obj is Disposable && !_disposables.contains(obj)) {
_disposables.add(obj);
}
}
/// Calls dispose on all tracked disposables and child scopes recursively (async).
Future<void> dispose() async {
// First dispose children scopes
for (final subScope in _scopeMap.values) {
await subScope.dispose();
}
_scopeMap.clear();
// Then dispose own disposables
for (final d in _disposables) {
try {
await d.dispose();
} catch (_) {}
}
_disposables.clear();
}
}

View File

@@ -1,7 +1,110 @@
import 'package:cherrypick/cherrypick.dart';
import 'package:cherrypick/cherrypick.dart' show Disposable, Module, Scope, CherryPick;
import 'dart:async';
import 'package:test/test.dart';
import '../mock_logger.dart';
// -----------------------------------------------------------------------------
// Вспомогательные классы для тестов
class AsyncExampleDisposable implements Disposable {
bool disposed = false;
@override
Future<void> dispose() async {
await Future.delayed(Duration(milliseconds: 10));
disposed = true;
}
}
class AsyncExampleModule extends Module {
@override
void builder(Scope scope) {
bind<AsyncExampleDisposable>().toProvide(() => AsyncExampleDisposable()).singleton();
}
}
class TestDisposable implements Disposable {
bool disposed = false;
@override
FutureOr<void> dispose() {
disposed = true;
}
}
class AnotherDisposable implements Disposable {
bool disposed = false;
@override
FutureOr<void> dispose() {
disposed = true;
}
}
class CountingDisposable implements Disposable {
int disposeCount = 0;
@override
FutureOr<void> dispose() {
disposeCount++;
}
}
class ModuleCountingDisposable extends Module {
@override
void builder(Scope scope) {
bind<CountingDisposable>().toProvide(() => CountingDisposable()).singleton();
}
}
class ModuleWithDisposable extends Module {
@override
void builder(Scope scope) {
bind<TestDisposable>().toProvide(() => TestDisposable()).singleton();
bind<AnotherDisposable>().toProvide(() => AnotherDisposable()).singleton();
bind<String>().toProvide(() => 'super string').singleton();
}
}
class TestModule<T> extends Module {
final T value;
final String? name;
TestModule({required this.value, this.name});
@override
void builder(Scope currentScope) {
if (name == null) {
bind<T>().toInstance(value);
} else {
bind<T>().withName(name!).toInstance(value);
}
}
}
class _InlineModule extends Module {
final void Function(Module, Scope) _builder;
_InlineModule(this._builder);
@override
void builder(Scope s) => _builder(this, s);
}
class AsyncCreatedDisposable implements Disposable {
bool disposed = false;
@override
void dispose() {
disposed = true;
}
}
class AsyncModule extends Module {
@override
void builder(Scope scope) {
bind<AsyncCreatedDisposable>()
.toProvideAsync(() async {
await Future.delayed(Duration(milliseconds: 10));
return AsyncCreatedDisposable();
})
.singleton();
}
}
// -----------------------------------------------------------------------------
void main() {
// --------------------------------------------------------------------------
group('Scope & Subscope Management', () {
@@ -10,12 +113,20 @@ void main() {
final scope = Scope(null, logger: logger);
expect(scope.parentScope, null);
});
test('Can open and retrieve the same subScope by key', () {
final logger = MockLogger();
final scope = Scope(null, logger: logger);
expect(Scope(scope, logger: logger), isNotNull); // эквивалент
});
test('closeSubScope removes subscope so next openSubScope returns new', () async {
final logger = MockLogger();
final scope = Scope(null, logger: logger);
final subScope = scope.openSubScope("child");
expect(scope.openSubScope("child"), same(subScope));
await scope.closeSubScope("child");
final newSubScope = scope.openSubScope("child");
expect(newSubScope, isNot(same(subScope)));
});
test('closeSubScope removes subscope so next openSubScope returns new', () {
final logger = MockLogger();
@@ -32,7 +143,6 @@ void main() {
final scope = Scope(null, logger: logger);
expect(() => scope.resolve<String>(), throwsA(isA<StateError>()));
});
test('Resolves value after adding a dependency', () {
final logger = MockLogger();
final expectedValue = 'test string';
@@ -40,7 +150,6 @@ void main() {
.installModules([TestModule<String>(value: expectedValue)]);
expect(scope.resolve<String>(), expectedValue);
});
test('Returns a value from parent scope', () {
final logger = MockLogger();
final expectedValue = 5;
@@ -48,10 +157,8 @@ void main() {
final scope = Scope(parentScope, logger: logger);
parentScope.installModules([TestModule<int>(value: expectedValue)]);
expect(scope.resolve<int>(), expectedValue);
});
test('Returns several values from parent container', () {
final logger = MockLogger();
final expectedIntValue = 5;
@@ -65,14 +172,12 @@ void main() {
expect(scope.resolve<int>(), expectedIntValue);
expect(scope.resolve<String>(), expectedStringValue);
});
test("Throws StateError if parent hasn't value too", () {
final logger = MockLogger();
final parentScope = Scope(null, logger: logger);
final scope = Scope(parentScope, logger: logger);
expect(() => scope.resolve<int>(), throwsA(isA<StateError>()));
});
test("After dropModules resolves fail", () {
final logger = MockLogger();
final scope = Scope(null, logger: logger)..installModules([TestModule<int>(value: 5)]);
@@ -94,7 +199,6 @@ void main() {
expect(scope.resolve<String>(named: "special"), "second");
expect(scope.resolve<String>(), "first");
});
test('Named binding does not clash with unnamed', () {
final logger = MockLogger();
final scope = Scope(null, logger: logger)
@@ -104,7 +208,6 @@ void main() {
expect(() => scope.resolve<String>(), throwsA(isA<StateError>()));
expect(scope.resolve<String>(named: "bar"), "foo");
});
test("tryResolve returns null for missing named", () {
final logger = MockLogger();
final scope = Scope(null, logger: logger)
@@ -142,7 +245,6 @@ void main() {
]);
expect(await scope.resolveAsync<String>(), "async value");
});
test('Resolve async provider', () async {
final logger = MockLogger();
final scope = Scope(null, logger: logger)
@@ -153,7 +255,6 @@ void main() {
]);
expect(await scope.resolveAsync<int>(), 7);
});
test('Resolve async provider with param', () async {
final logger = MockLogger();
final scope = Scope(null, logger: logger)
@@ -165,7 +266,6 @@ void main() {
expect(await scope.resolveAsync<int>(params: 2), 6);
expect(() => scope.resolveAsync<int>(), throwsA(isA<StateError>()));
});
test('tryResolveAsync returns null for missing', () async {
final logger = MockLogger();
final scope = Scope(null, logger: logger);
@@ -181,42 +281,86 @@ void main() {
final scope = Scope(null, logger: logger);
expect(scope.tryResolve<int>(), isNull);
});
// Не реализован:
// test("Container bind() throws state error (if it's parent already has a resolver)", () {
// final parentScope = new Scope(null).installModules([TestModule<String>(value: "string one")]);
// final scope = new Scope(parentScope);
// expect(
// () => scope.installModules([TestModule<String>(value: "string two")]),
// throwsA(isA<StateError>()));
// });
});
}
// ----------------------------------------------------------------------------
// Вспомогательные модули
// --------------------------------------------------------------------------
group('Disposable resource management', () {
test('scope.disposeAsync calls dispose on singleton disposable', () async {
final scope = CherryPick.openRootScope();
scope.installModules([ModuleWithDisposable()]);
final t = scope.resolve<TestDisposable>();
expect(t.disposed, isFalse);
await scope.dispose();
expect(t.disposed, isTrue);
});
test('scope.disposeAsync calls dispose on all unique disposables', () async {
final scope = Scope(null, logger: MockLogger());
scope.installModules([ModuleWithDisposable()]);
final t1 = scope.resolve<TestDisposable>();
final t2 = scope.resolve<AnotherDisposable>();
expect(t1.disposed, isFalse);
expect(t2.disposed, isFalse);
await scope.dispose();
expect(t1.disposed, isTrue);
expect(t2.disposed, isTrue);
});
test('calling disposeAsync twice does not throw and not call twice', () async {
final scope = CherryPick.openRootScope();
scope.installModules([ModuleWithDisposable()]);
final t = scope.resolve<TestDisposable>();
await scope.dispose();
await scope.dispose();
expect(t.disposed, isTrue);
});
test('Non-disposable dependency is ignored by scope.disposeAsync', () async {
final scope = CherryPick.openRootScope();
scope.installModules([ModuleWithDisposable()]);
final s = scope.resolve<String>();
expect(s, 'super string');
await scope.dispose();
});
});
class TestModule<T> extends Module {
final T value;
final String? name;
// --------------------------------------------------------------------------
// Расширенные edge-тесты для dispose и subScope
group('Scope/subScope dispose edge cases', () {
test('Dispose called in closed subScope only', () async {
final root = CherryPick.openRootScope();
final sub = root.openSubScope('feature')..installModules([ModuleCountingDisposable()]);
final d = sub.resolve<CountingDisposable>();
expect(d.disposeCount, 0);
TestModule({required this.value, this.name});
@override
void builder(Scope currentScope) {
if (name == null) {
bind<T>().toInstance(value);
} else {
bind<T>().withName(name!).toInstance(value);
}
}
}
await root.closeSubScope('feature');
expect(d.disposeCount, 1); // dispose должен быть вызван
/// Вспомогательный модуль для подстановки builder'а через конструктор
class _InlineModule extends Module {
final void Function(Module, Scope) _builder;
_InlineModule(this._builder);
// Повторное закрытие не вызывает double-dispose
await root.closeSubScope('feature');
expect(d.disposeCount, 1);
@override
void builder(Scope s) => _builder(this, s);
}
// Повторное открытие subScope создает NEW instance (dispose на старый не вызовется снова)
final sub2 = root.openSubScope('feature')..installModules([ModuleCountingDisposable()]);
final d2 = sub2.resolve<CountingDisposable>();
expect(identical(d, d2), isFalse);
await root.closeSubScope('feature');
expect(d2.disposeCount, 1);
});
test('Dispose for all nested subScopes on root disposeAsync', () async {
final root = CherryPick.openRootScope();
root.openSubScope('a').openSubScope('b').installModules([ModuleCountingDisposable()]);
final d = root.openSubScope('a').openSubScope('b').resolve<CountingDisposable>();
await root.dispose();
expect(d.disposeCount, 1);
});
});
// --------------------------------------------------------------------------
group('Async disposable (Future test)', () {
test('Async Disposable is awaited on disposeAsync', () async {
final scope = CherryPick.openRootScope()..installModules([AsyncExampleModule()]);
final d = scope.resolve<AsyncExampleDisposable>();
expect(d.disposed, false);
await scope.dispose();
expect(d.disposed, true);
});
});
}