feat(cherrypick): optimize Scope resolution with incremental index, silent observer, and fast-path

This commit is contained in:
Klim
2026-04-19 19:14:23 +03:00
parent 3331a3ee9c
commit 3c4fc67166
2 changed files with 281 additions and 68 deletions

View File

@@ -19,7 +19,7 @@ import 'package:cherrypick/src/global_cycle_detector.dart';
import 'package:cherrypick/src/binding_resolver.dart'; import 'package:cherrypick/src/binding_resolver.dart';
import 'package:cherrypick/src/module.dart'; import 'package:cherrypick/src/module.dart';
import 'package:cherrypick/src/observer.dart'; import 'package:cherrypick/src/observer.dart';
// import 'package:cherrypick/src/log_format.dart';
/// Represents a DI scope (container) for modules, subscopes, /// Represents a DI scope (container) for modules, subscopes,
/// and dependency resolution (sync/async) in CherryPick. /// and dependency resolution (sync/async) in CherryPick.
@@ -60,6 +60,13 @@ class Scope with CycleDetectionMixin, GlobalCycleDetectionMixin {
@override @override
CherryPickObserver get observer => _observer; CherryPickObserver get observer => _observer;
bool get _isSilentObserver => _observer is SilentCherryPickObserver;
bool get _canUseDirectResolvePath =>
_isSilentObserver &&
!isCycleDetectionEnabled &&
!isGlobalCycleDetectionEnabled;
/// COLLECTS all resolved instances that implement [Disposable]. /// COLLECTS all resolved instances that implement [Disposable].
final Set<Disposable> _disposables = HashSet(); final Set<Disposable> _disposables = HashSet();
@@ -71,21 +78,23 @@ class Scope with CycleDetectionMixin, GlobalCycleDetectionMixin {
Scope(this._parentScope, {required CherryPickObserver observer}) Scope(this._parentScope, {required CherryPickObserver observer})
: _observer = observer { : _observer = observer {
setScopeId(_generateScopeId()); setScopeId(_generateScopeId());
observer.onScopeOpened(scopeId ?? 'NO_ID'); if (!_isSilentObserver) {
observer.onDiagnostic( observer.onScopeOpened(scopeId ?? 'NO_ID');
'Scope created: ${scopeId ?? 'NO_ID'}', observer.onDiagnostic(
details: { 'Scope created: ${scopeId ?? 'NO_ID'}',
'type': 'Scope', details: {
'name': scopeId ?? 'NO_ID', 'type': 'Scope',
if (_parentScope?.scopeId != null) 'parent': _parentScope!.scopeId, 'name': scopeId ?? 'NO_ID',
'description': 'scope created', if (_parentScope?.scopeId != null) 'parent': _parentScope!.scopeId,
}, 'description': 'scope created',
); },
);
}
} }
final Set<Module> _modulesList = HashSet(); final Set<Module> _modulesList = HashSet();
// индекс для мгновенного поиска binding’ов // index for fast binding lookup
final Map<Object, Map<String?, BindingResolver>> _bindingResolvers = {}; final Map<Object, Map<String?, BindingResolver>> _bindingResolvers = {};
/// Generates a unique identifier string for this scope instance. /// Generates a unique identifier string for this scope instance.
@@ -119,16 +128,18 @@ class Scope with CycleDetectionMixin, GlobalCycleDetectionMixin {
childScope.enableGlobalCycleDetection(); childScope.enableGlobalCycleDetection();
} }
_scopeMap[name] = childScope; _scopeMap[name] = childScope;
observer.onDiagnostic( if (!_isSilentObserver) {
'SubScope created: $name', observer.onDiagnostic(
details: { 'SubScope created: $name',
'type': 'SubScope', details: {
'name': name, 'type': 'SubScope',
'id': childScope.scopeId, 'name': name,
if (scopeId != null) 'parent': scopeId, 'id': childScope.scopeId,
'description': 'subscope created', if (scopeId != null) 'parent': scopeId,
}, 'description': 'subscope created',
); },
);
}
} }
return _scopeMap[name]!; return _scopeMap[name]!;
} }
@@ -149,17 +160,19 @@ class Scope with CycleDetectionMixin, GlobalCycleDetectionMixin {
if (childScope.scopeId != null) { if (childScope.scopeId != null) {
GlobalCycleDetector.instance.removeScopeDetector(childScope.scopeId!); GlobalCycleDetector.instance.removeScopeDetector(childScope.scopeId!);
} }
observer.onScopeClosed(childScope.scopeId ?? name); if (!_isSilentObserver) {
observer.onDiagnostic( observer.onScopeClosed(childScope.scopeId ?? name);
'SubScope closed: $name', observer.onDiagnostic(
details: { 'SubScope closed: $name',
'type': 'SubScope', details: {
'name': name, 'type': 'SubScope',
'id': childScope.scopeId, 'name': name,
if (scopeId != null) 'parent': scopeId, 'id': childScope.scopeId,
'description': 'subscope closed', if (scopeId != null) 'parent': scopeId,
}, 'description': 'subscope closed',
); },
);
}
} }
_scopeMap.remove(name); _scopeMap.remove(name);
} }
@@ -175,30 +188,34 @@ class Scope with CycleDetectionMixin, GlobalCycleDetectionMixin {
/// ``` /// ```
Scope installModules(List<Module> modules) { Scope installModules(List<Module> modules) {
_modulesList.addAll(modules); _modulesList.addAll(modules);
if (modules.isNotEmpty) { if (!_isSilentObserver && modules.isNotEmpty) {
observer.onModulesInstalled( observer.onModulesInstalled(
modules.map((m) => m.runtimeType.toString()).toList(), modules.map((m) => m.runtimeType.toString()).toList(),
scopeName: scopeId, scopeName: scopeId,
); );
} }
for (var module in modules) { for (var module in modules) {
observer.onDiagnostic( if (!_isSilentObserver) {
'Module installed: ${module.runtimeType}', observer.onDiagnostic(
details: { 'Module installed: ${module.runtimeType}',
'type': 'Module', details: {
'name': module.runtimeType.toString(), 'type': 'Module',
'scope': scopeId, 'name': module.runtimeType.toString(),
'description': 'module installed', 'scope': scopeId,
}, 'description': 'module installed',
); },
);
}
module.builder(this); module.builder(this);
// Associate bindings with this scope's observer // Associate bindings with this scope's observer
for (final binding in module.bindingSet) { for (final binding in module.bindingSet) {
binding.observer = observer; binding.observer = observer;
binding.logAllDeferred(); if (!_isSilentObserver) {
binding.logAllDeferred();
}
} }
_addModuleToIndex(module);
} }
_rebuildResolversIndex();
return this; return this;
} }
@@ -212,20 +229,22 @@ class Scope with CycleDetectionMixin, GlobalCycleDetectionMixin {
/// testScope.dropModules(); /// testScope.dropModules();
/// ``` /// ```
Scope dropModules() { Scope dropModules() {
if (_modulesList.isNotEmpty) { if (!_isSilentObserver && _modulesList.isNotEmpty) {
observer.onModulesRemoved( observer.onModulesRemoved(
_modulesList.map((m) => m.runtimeType.toString()).toList(), _modulesList.map((m) => m.runtimeType.toString()).toList(),
scopeName: scopeId, scopeName: scopeId,
); );
} }
observer.onDiagnostic( if (!_isSilentObserver) {
'Modules dropped for scope: $scopeId', observer.onDiagnostic(
details: { 'Modules dropped for scope: $scopeId',
'type': 'Scope', details: {
'name': scopeId, 'type': 'Scope',
'description': 'modules dropped', 'name': scopeId,
}, 'description': 'modules dropped',
); },
);
}
_modulesList.clear(); _modulesList.clear();
_rebuildResolversIndex(); _rebuildResolversIndex();
return this; return this;
@@ -242,6 +261,16 @@ class Scope with CycleDetectionMixin, GlobalCycleDetectionMixin {
/// final special = scope.resolve<Service>(named: 'special'); /// final special = scope.resolve<Service>(named: 'special');
/// ``` /// ```
T resolve<T>({String? named, dynamic params}) { T resolve<T>({String? named, dynamic params}) {
if (_canUseDirectResolvePath) {
final result = _tryResolveInternal<T>(named: named, params: params);
if (result == null) {
throw StateError(
'Can\'t resolve dependency `$T`. Maybe you forget register it?');
}
if (result is Disposable) _disposables.add(result);
return result;
}
observer.onInstanceRequested(T.toString(), T, scopeName: scopeId); observer.onInstanceRequested(T.toString(), T, scopeName: scopeId);
T result; T result;
if (isGlobalCycleDetectionEnabled) { if (isGlobalCycleDetectionEnabled) {
@@ -315,6 +344,12 @@ class Scope with CycleDetectionMixin, GlobalCycleDetectionMixin {
/// final maybeDb = scope.tryResolve<Database>(); /// final maybeDb = scope.tryResolve<Database>();
/// ``` /// ```
T? tryResolve<T>({String? named, dynamic params}) { T? tryResolve<T>({String? named, dynamic params}) {
if (_canUseDirectResolvePath) {
final result = _tryResolveInternal<T>(named: named, params: params);
if (result != null && result is Disposable) _disposables.add(result);
return result;
}
T? result; T? result;
if (isGlobalCycleDetectionEnabled) { if (isGlobalCycleDetectionEnabled) {
result = withGlobalCycleDetection<T?>(T, named, () { result = withGlobalCycleDetection<T?>(T, named, () {
@@ -358,6 +393,21 @@ class Scope with CycleDetectionMixin, GlobalCycleDetectionMixin {
/// final special = await scope.resolveAsync<Service>(named: "special"); /// final special = await scope.resolveAsync<Service>(named: "special");
/// ``` /// ```
Future<T> resolveAsync<T>({String? named, dynamic params}) async { Future<T> resolveAsync<T>({String? named, dynamic params}) async {
if (_canUseDirectResolvePath) {
final result =
await _tryResolveAsyncInternal<T>(named: named, params: params);
if (result == null) {
throw StateError(
"Can't resolve async dependency `$T`. Maybe you forget register it?");
}
if (result is Disposable) _disposables.add(result);
return result;
}
return _resolveAsyncWithObserverPath<T>(named: named, params: params);
}
Future<T> _resolveAsyncWithObserverPath<T>(
{String? named, dynamic params}) async {
T result; T result;
if (isGlobalCycleDetectionEnabled) { if (isGlobalCycleDetectionEnabled) {
result = await withGlobalCycleDetection<Future<T>>(T, named, () async { result = await withGlobalCycleDetection<Future<T>>(T, named, () async {
@@ -413,6 +463,17 @@ class Scope with CycleDetectionMixin, GlobalCycleDetectionMixin {
/// final user = await scope.tryResolveAsync<User>(); /// final user = await scope.tryResolveAsync<User>();
/// ``` /// ```
Future<T?> tryResolveAsync<T>({String? named, dynamic params}) async { Future<T?> tryResolveAsync<T>({String? named, dynamic params}) async {
if (_canUseDirectResolvePath) {
final result =
await _tryResolveAsyncInternal<T>(named: named, params: params);
if (result != null && result is Disposable) _disposables.add(result);
return result;
}
return _tryResolveAsyncWithObserverPath<T>(named: named, params: params);
}
Future<T?> _tryResolveAsyncWithObserverPath<T>(
{String? named, dynamic params}) async {
T? result; T? result;
if (isGlobalCycleDetectionEnabled) { if (isGlobalCycleDetectionEnabled) {
result = await withGlobalCycleDetection<Future<T?>>(T, named, () async { result = await withGlobalCycleDetection<Future<T?>>(T, named, () async {
@@ -441,12 +502,12 @@ class Scope with CycleDetectionMixin, GlobalCycleDetectionMixin {
} }
/// Direct async resolution for [T] without cycle check. Returns null if missing. Internal use only. /// Direct async resolution for [T] without cycle check. Returns null if missing. Internal use only.
Future<T?> _tryResolveAsyncInternal<T>( Future<T?> _tryResolveAsyncInternal<T>({String? named, dynamic params}) {
{String? named, dynamic params}) async {
final resolver = _findBindingResolver<T>(named); final resolver = _findBindingResolver<T>(named);
// 1 - Try from own modules; 2 - Fallback to parent // 1 - Try from own modules; 2 - Fallback to parent
return resolver?.resolveAsync(params) ?? return resolver?.resolveAsync(params) ??
_parentScope?.tryResolveAsync(named: named, params: params); _parentScope?.tryResolveAsync(named: named, params: params) ??
Future<T?>.value(null);
} }
/// Looks up the [BindingResolver] for [T] and [named] within this scope. /// Looks up the [BindingResolver] for [T] and [named] within this scope.
@@ -454,23 +515,27 @@ class Scope with CycleDetectionMixin, GlobalCycleDetectionMixin {
BindingResolver<T>? _findBindingResolver<T>(String? named) => BindingResolver<T>? _findBindingResolver<T>(String? named) =>
_bindingResolvers[T]?[named] as BindingResolver<T>?; _bindingResolvers[T]?[named] as BindingResolver<T>?;
void _addModuleToIndex(Module module) {
for (var binding in module.bindingSet) {
_bindingResolvers.putIfAbsent(binding.key, () => {});
final nameKey = binding.isNamed ? binding.name : null;
_bindingResolvers[binding.key]![nameKey] = binding.resolver!;
}
}
/// Rebuilds the internal index of all [BindingResolver]s from installed modules. /// Rebuilds the internal index of all [BindingResolver]s from installed modules.
/// Called after [installModules] and [dropModules]. Internal use only. /// Called after [dropModules]. Internal use only.
void _rebuildResolversIndex() { void _rebuildResolversIndex() {
_bindingResolvers.clear(); _bindingResolvers.clear();
for (var module in _modulesList) { for (var module in _modulesList) {
for (var binding in module.bindingSet) { _addModuleToIndex(module);
_bindingResolvers.putIfAbsent(binding.key, () => {});
final nameKey = binding.isNamed ? binding.name : null;
_bindingResolvers[binding.key]![nameKey] = binding.resolver!;
}
} }
} }
/// Tracks resolved [Disposable] instances, to ensure dispose is called automatically. /// Tracks resolved [Disposable] instances, to ensure dispose is called automatically.
/// Internal use only. /// Internal use only.
void _trackDisposable(Object? obj) { void _trackDisposable(Object? obj) {
if (obj is Disposable && !_disposables.contains(obj)) { if (obj is Disposable) {
_disposables.add(obj); _disposables.add(obj);
} }
} }
@@ -487,14 +552,14 @@ class Scope with CycleDetectionMixin, GlobalCycleDetectionMixin {
/// ``` /// ```
Future<void> dispose() async { Future<void> dispose() async {
// Create copies to avoid concurrent modification // Create copies to avoid concurrent modification
final scopesCopy = Map<String, Scope>.from(_scopeMap); final scopes = _scopeMap.values.toList();
for (final subScope in scopesCopy.values) { for (final subScope in scopes) {
await subScope.dispose(); await subScope.dispose();
} }
_scopeMap.clear(); _scopeMap.clear();
final disposablesCopy = Set<Disposable>.from(_disposables); final disposables = _disposables.toList();
for (final d in disposablesCopy) { for (final d in disposables) {
await d.dispose(); await d.dispose();
} }
_disposables.clear(); _disposables.clear();

View File

@@ -0,0 +1,148 @@
import 'package:cherrypick/cherrypick.dart';
import 'package:test/test.dart';
class _IntModule extends Module {
final int value;
final void Function()? onBuild;
_IntModule(this.value, {this.onBuild});
@override
void builder(Scope currentScope) {
onBuild?.call();
bind<int>().toInstance(value);
}
}
class _StringModule extends Module {
final String value;
_StringModule(this.value);
@override
void builder(Scope currentScope) {
bind<String>().toInstance(value);
}
}
class _NamedIntModule extends Module {
final String name;
final int value;
_NamedIntModule(this.name, this.value);
@override
void builder(Scope currentScope) {
bind<int>().withName(name).toInstance(value);
}
}
class _DisposableModule extends Module {
@override
void builder(Scope currentScope) {
bind<_DisposableService>().toProvide(() => _DisposableService());
}
}
class _DisposableService implements Disposable {
bool disposed = false;
@override
Future<void> dispose() async {
disposed = true;
}
}
void main() {
tearDown(() => CherryPick.closeRootScope());
group('SilentCherryPickObserver fast-path', () {
test('resolve with default silent observer uses fast-path', () {
final scope = CherryPick.openSafeRootScope();
scope.installModules([_IntModule(42)]);
final result = scope.resolve<int>();
expect(result, 42);
});
test('resolveAsync with default silent observer uses fast-path', () async {
final scope = CherryPick.openSafeRootScope();
scope.installModules([_IntModule(42)]);
final result = await scope.resolveAsync<int>();
expect(result, 42);
});
test('tryResolve with default silent observer uses fast-path', () {
final scope = CherryPick.openSafeRootScope();
scope.installModules([_IntModule(42)]);
final result = scope.tryResolve<int>();
expect(result, 42);
});
test('tryResolveAsync with default silent observer uses fast-path',
() async {
final scope = CherryPick.openSafeRootScope();
scope.installModules([_IntModule(42)]);
final result = await scope.tryResolveAsync<int>();
expect(result, 42);
});
test('installModules with silent observer skips diagnostics', () {
final scope = CherryPick.openSafeRootScope();
var buildCalls = 0;
scope.installModules([_IntModule(42, onBuild: () => buildCalls++)]);
expect(buildCalls, 1);
expect(scope.resolve<int>(), 42);
});
test('Disposable is tracked even in silent observer fast-path', () async {
final scope = CherryPick.openSafeRootScope();
scope.installModules([_DisposableModule()]);
final service = scope.resolve<_DisposableService>();
expect(service.disposed, false);
await scope.dispose();
expect(service.disposed, true);
});
});
group('Incremental module index', () {
test('Multiple installModules calls accumulate bindings', () {
final scope = CherryPick.openSafeRootScope();
scope.installModules([_IntModule(1)]);
scope.installModules([_StringModule('two')]);
expect(scope.resolve<int>(), 1);
expect(scope.resolve<String>(), 'two');
});
test('dropModules clears all bindings', () {
final scope = CherryPick.openSafeRootScope();
scope.installModules([_IntModule(1)]);
scope.dropModules();
expect(() => scope.resolve<int>(), throwsStateError);
});
test('Re-installing modules after dropModules works', () {
final scope = CherryPick.openSafeRootScope();
scope.installModules([_IntModule(1)]);
scope.dropModules();
scope.installModules([_IntModule(2)]);
expect(scope.resolve<int>(), 2);
});
test('Named bindings are indexed incrementally', () {
final scope = CherryPick.openSafeRootScope();
scope.installModules([_NamedIntModule('a', 1)]);
scope.installModules([_NamedIntModule('b', 2)]);
expect(scope.resolve<int>(named: 'a'), 1);
expect(scope.resolve<int>(named: 'b'), 2);
});
});
}