.NET 通过源码深究依赖注入原理
依赖注入 (DI) 是.NET中一个非常重要的软件设计模式,它可以帮助我们更好地管理和组织组件,提高代码的可读性,扩展性和可测试性。在日常工作中,我们一定遇见过这些问题或者疑惑。
- Singleton服务为什么不能依赖Scoped服务?
- 多个构造函数的选择机制?
- 源码是如何识别循环依赖的?
虽然我们可能已经知道了答案,但本文将通过阅读CLR源码的方式来学习DI实现机制,同时也更加深入地理解上述问题。如果您不想阅读源码,可以直接跳至文末的解决方案。
一、源码解读
理论知识
理论篇可以先看一下,防止在下文代码不知道这些对象的作用。如果有些概念不是很清晰可以先记着,带入下文源码,应该就可以理解
ServiceProvider: ServiceProvider(依赖注入容器)不仅对外提供GetService()、GetRequiredService()方法,还可以方便地注册和管理应用程序需要的各种服务。
通过创建ServiceProvider的方式,我们可以更好地理解管理和控制服务实例的生命周期和依赖关系。
应用程序级别的根级ServiceProvider
.NET Core应用程序通常会使用一个应用程序级别的根级ServiceProvider,它是全局唯一的,并且负责维护所有单例服务的实例。这个实例通常是由WebHostBuilder、HostBuilder或ServiceCollection等类创建和配置的,可以通过IServiceProvider接口来访问。每个请求的作用域级别的ServiceProvider
除了根级ServiceProvider之外,在.NET Core中还可以创建每个请求的作用域级别的ServiceProvider,它通常用于管理Scoped和Transient服务的生命周期和依赖关系。每个作用域级别的ServiceProvider都有自己独立的作用域,可以通过IServiceScopeFactory创建,同时也继承了根级ServiceProvider中注册的所有单例服务的实例。自定义级别的ServiceProvider
在某些情况下,我们可能需要自定义级别的ServiceProvider来满足特定的业务需求,例如,将多个ServiceProvider组合起来以提供更高级别的服务解析和管理功能。此时,我们可以通过实现IServiceProviderFactory接口和IServiceProviderBuilder接口来创建和配置自定义级别的ServiceProvider,从而实现更灵活、可扩展的依赖注入框架。
生命周期管理: 我们可以将依赖注入容器看作一个树形结构,其中root节点的子节点是Scoped节点,每个Scoped节点的子节点是Transient节点(如果存在)。在容器初始化时,会在root节点下创建和缓存所有单例服务的实例,以及创建第一个Scoped节点。每个Scoped节点下都有一个独立的作用域,用于管理Scoped服务的生命周期和依赖关系,同时还继承了父级节点(即root或其他Scoped节点)的所有单例服务的实例。
在处理每个新的请求时,依赖注入容器会创建一个新的Scoped节点,并在该节点下创建和缓存该请求所需的所有Scoped服务的实例。在完成请求处理后,该Scoped节点及其下属的服务实例也将被销毁,从而确保Scoped服务实例的生命周期与请求的作用域相对应。
重要对象
IServiceCollection: 用于注册应用程序所需的服务实例,并将其添加到依赖注入容器中。
IServiceScopeFactory: 用于创建依赖注入作用域(IServiceScope)的工厂类。每个IServiceScope都可以独立地管理Scoped和Transient类型的服务实例,并在作用域结束时释放所有资源。IServiceScope通过ServiceProvider属性来访问该作用域内的服务实例
ServiceProvider: 可以看作是一个服务容器,它可以方便地注册、提供和管理应用程序需要的各种服务。还支持创建依赖注入作用域(IServiceScope),可以更好地管理和控制服务实例的生命周期和依赖关系
IServiceProviderFactory: 创建最终的依赖注入容器(IServiceProvider),提供默认的DefaultServiceProviderFactory(也就是官方自带的IOC),也支持自定义的,比如autofac的AutofacServiceProviderFactory工厂。
ServiceProviderEngineScope: 实现了IServiceProvider和IDisposable接口,用于创建和管理依赖注入作用域(Scope)。通过使用ServiceProviderEngineScope,我们可以访问依赖注入作用域中的服务实例,并实现Scoped和Transient类型的服务实例的生命周期管理。作用域机制可以帮助我们更好地管理和控制应用程序的各个组件之间的依赖关系
CallSiteFactory: 通常由依赖注入容器(如ServiceProvider)在服务解析过程中使用。当容器需要解析某个服务时,它会创建一个CallSiteFactory对象,并使用其中的静态方法来创建对应的ServiceCallSite对象。然后,容器会将这些ServiceCallSite对象组合成一个树形结构,最终构建出整个服务实例的解析树。
ServiceCallSite: 表示服务的解析过程。它包含了服务类型、服务的生命周期、以及从容器中获取服务实例的方法等信息
CallSiteVisitor: 通常由依赖注入容器(如ServiceProvider)在服务解析过程中使用。当容器需要解析某个服务时,它会创建一个ServiceCallSite的对象图,并将其传递给CallSiteVisitor进行遍历和访问。CallSiteVisitor通过调用不同节点的虚拟方法,将每个节点的信息收集起来,并最终构建出服务实例的解析树。
CallSiteValidator 通常由依赖注入容器(如ServiceProvider)在服务解析过程中使用,用于验证ServiceCallSite对象图的正确性。它提供了一组检查方法,可以检测ServiceCallSite对象图中可能存在的循环依赖、未注册的服务类型和生命周期问题等。
阅读源码
以下是源代码的部分删减和修改,以便于更好地理解
为了更好地理解依赖注入的整个流程,可以根据依赖注入容器将其简单理解为以下两个模块:
- 服务注册:将服务及其对应的生命周期(例如 Singleton、Scoped 或 Transient)添加到依赖注入容器中。
- 服务提供:在应用程序运行时,依赖注入容器会根据需要创建并提供服务的实例,以满足应用程序中各个类之间的依赖关系。
配置ConfigureServices,将服务对象(ServiceDescriptor)注册到了IServiceCollection集合,构建Host主机的时候,会调用BuildServiceProvide()方法创建IServiceProvider,并获取相关服务。
public IWebHost Build(){ var hostingServices = BuildCommonServices(out var hostingStartupErrors);// 构建WebHost通用服务 var hostingServiceProvider = GetProviderFromFactory(hostingServices); // 获取ServiceProvider IServiceProvider GetProviderFromFactory(IServiceCollection collection) { // 构建IServiceProvider对象 var provider = collection.BuildServiceProvider(); // 获取服务 var factory = provider.GetService<IServiceProviderFactory<IServiceCollection>>(); // 是否使用默认的DefaultServiceProviderFactory类 if (factory != null && !(factory is DefaultServiceProviderFactory)) { using (provider) { return factory.CreateServiceProvider(factory.CreateBuilder(collection)); } } return provider; }}
BuildServiceProvider是IServiceCollection接口的扩展方法。该方法用于创建一个IServiceProvider接口实例,并将已注册到IServiceCollection容器中的服务对象注入到该实例中。
public static ServiceProvider BuildServiceProvider(this IServiceCollection services, ServiceProviderOptions options){ // 生成ServiceProvider对象 return new ServiceProvider(services, options);}
ServiceProvider类的构造函数,创建依赖注入容器,并将服务描述信息加载到容器中
internal ServiceProvider(ICollection<ServiceDescriptor> serviceDescriptors, ServiceProviderOptions options){ // 创建一个根级别的服务引擎作用域 Root = new ServiceProviderEngineScope(this, isRootScope: true); // 获取服务引擎用于解析依赖关系 _engine = GetEngine(); // 访问器,动态创建服务 _createServiceAccessor = CreateServiceAccessor; // 缓存已经解析出来的服务实例(线程安全) _realizedServices = new ConcurrentDictionary<Type, Func<ServiceProviderEngineScope, object?>>(); // CallSiteFactory用于创建和缓存服务的调用站点(ServiceCallSite) CallSiteFactory = new CallSiteFactory(serviceDescriptors); // 添加内置的服务 CallSiteFactory.Add(typeof(IServiceProvider), new ServiceProviderCallSite()); CallSiteFactory.Add(typeof(IServiceScopeFactory), new ConstantCallSite(typeof(IServiceScopeFactory), Root)); CallSiteFactory.Add(typeof(IServiceProviderIsService), new ConstantCallSite(typeof(IServiceProviderIsService), CallSiteFactory)); // ValidateScopes属性为true,表示需要验证服务范围 if (options.ValidateScopes) { _callSiteValidator = new CallSiteValidator(); } // ValidateOnBuild属性为true,需要检查所有服务是否能够成功创建 if (options.ValidateOnBuild) { List<Exception>? exceptions = null; foreach (ServiceDescriptor serviceDescriptor in serviceDescriptors) { ValidateService(serviceDescriptor); } }}
当ValidateOnBuild属性为true时,进入到ValidateService方法中。ValidateService方法使用CallSiteFactory对象的GetCallSite方法来获取对应的ServiceCallSite对象,并将其保存到callSite变量中。如果callSite不为null,表示该服务可以被成功创建,则调用OnCreate方法。
此时我们需要知道并理解ServiceCallSite 对象
ServiceCallSite记录着从根调用站点到当前服务实例的一条依赖链。在DI容器中,每一个已注册的服务都对应一个ServiceCallSite,而所有的CallSite又组成了整个 DI 系统的拓扑结构。在 DI 系统初始化时,容器会通过递归调用ServiceCallSite上的信息,来完成整个DI容器的配置和初始化。
internal abstract class ServiceCallSite{ protected ServiceCallSite(ResultCache cache) { Cache = cache; } // 服务类型 public abstract Type ServiceType { get; } // 实现类型 public abstract Type ImplementationType { get; } // 调用链类型(Scope、Singleton、Factory、Constructor、CreateInstance等) public abstract CallSiteKind Kind { get; } public ResultCache Cache { get; } // 是否需要捕获可释放资源,类似IDisposable接口 public bool CaptureDisposable => ImplementationType == null || typeof(IDisposable).IsAssignableFrom(ImplementationType) || typeof(IAsyncDisposable).IsAssignableFrom(ImplementationType);}
有没有发现,ServiceCallSite和ServiceDescriptor有几分相似。那么他们有什么关系和区别呢?
ServiceDescriptor用于描述一个服务实例的信息,包括服务类型、实现类型、生命周期等。在容器注册服务时使用的。
ServiceCallSite则表示服务调用链节点,是ServiceDescriptor的运行时表示形式,即在Resolve服务时,ServiceDescriptor会被转换为相应的ServiceCallSite。ServiceCallSite包含了解析服务所需要的全部信息,包括服务类型、实现工厂、参数列表等,它能够通过递归访问自己的子节点来构建出完整的服务调用链。
ValidateService方法验证服务是否能够正常创建
private void ValidateService(ServiceDescriptor descriptor){ // 这个方法中出现了循环依赖和多构造函数 ServiceCallSite callSite = CallSiteFactory.GetCallSite(descriptor, new CallSiteChain()); if (callSite != null) { // 这个方法中进行依赖校验 OnCreate(callSite); }}
我们先看GetCallSite方法,GetCallSite尝试从缓存中获取,如果缓存中不存在,则创建CreateCallSite。
internal ServiceCallSite GetCallSite(Type serviceType, CallSiteChain callSiteChain) => _callSiteCache.TryGetValue(new ServiceCacheKey(serviceType, DefaultSlot), out ServiceCallSite site) ? site : CreateCallSite(serviceType, callSiteChain);
CreateCallSite是非常重要的方法,它负责创建和缓存CallSite对象,并为整个依赖注入容器的服务解析提供了基础支持
private ServiceCallSite CreateCallSite(Type serviceType, CallSiteChain callSiteChain){ var callsiteLock = _callSiteLocks.GetOrAdd(serviceType, static _ => new object()); // 保证对CallSite缓存的线程安全。由于多个服务之间可能存在依赖关系,因此需要确保同一时间只有一个服务的CallSite被创建和缓存 lock (callsiteLock) { // 哦吼,出现了 检查是否存在循环依赖关系,以避免产生无限递归调用 callSiteChain.CheckCircularDependency(serviceType); // 依次尝试创建精确类型、开放泛型类型和IEnumerable类型的CallSite对象,并返回第一个成功创建的对象 ServiceCallSite callSite = TryCreateExact(serviceType, callSiteChain) ?? TryCreateOpenGeneric(serviceType, callSiteChain) ?? TryCreateEnumerable(serviceType, callSiteChain); return callSite; }}
此时,我们发现了判断循环依赖的方法,他是如何实现的呢?我们就要看一下callSiteChain对象了。callSiteChain用于描述服务调用站点(CallSite)之间的依赖关系。callSiteChain使用DIctionary容器存储当前链路上的CallSite。如果容器存在当前服务,说明存在循环依赖。
举个栗子: A->B B->A
- 创建服务A,将A添加到callSiteChain
- 创建服务B,将B添加到callSiteChain
- 此时,又到服务A,callSiteChain存在服务A,判定为循环依赖
public CallSiteChain(){ _callSiteChain = new Dictionary<Type, ChainItemInfo>();}public void CheckCircularDependency(Type serviceType){ if (_callSiteChain.ContainsKey(serviceType)) { throw new InvalidOperationException(CreateCircularDependencyExceptionMessage(serviceType)); }}
我们选择TryCreateExact方法,进入CreateConstructorCallSite方法,该方法创建和缓存ConstructorCallSite对象。此时您就看见多个构造参数是如何进行选择的啦!如果存在多个构造函数,但其中某个构造函数的参数类型是其他构造函数的子集,则返回该构造函数对应的ConstructorCallSite对象
private ServiceCallSite CreateConstructorCallSite( ResultCache lifetime, Type serviceType, [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] Type implementationType, CallSiteChain callSiteChain) { try { // 将当前服务加入callSiteChain,以便后续的依赖解析过程中使用 callSiteChain.Add(serviceType, implementationType); // 获取所有构造函数 ConstructorInfo[] constructors = implementationType.GetConstructors(); ServiceCallSite[] parameterCallSites = null; // 0个构造函数 if (constructors.Length == 0) { throw new InvalidOperationException(SR.Format(SR.NoConstructorMatch, implementationType)); } // 1个构造函数 else if (constructors.Length == 1) { ConstructorInfo constructor = constructors[0]; ParameterInfo[] parameters = constructor.GetParameters(); if (parameters.Length == 0) { return new ConstructorCallSite(lifetime, serviceType, constructor); } parameterCallSites = CreateArgumentCallSites( implementationType, callSiteChain, parameters, throwIfCallSiteNotFound: true); return new ConstructorCallSite(lifetime, serviceType, constructor, parameterCallSites); } // 多个构造函数如何选择,终于等到你,还好我没放弃0.0 Array.Sort(constructors, (a, b) => b.GetParameters().Length.CompareTo(a.GetParameters().Length)); ConstructorInfo bestConstructor = null; HashSet<Type> bestConstructorParameterTypes = null; for (int i = 0; i < constructors.Length; i++) { ParameterInfo[] parameters = constructors[i].GetParameters(); ServiceCallSite[] currentParameterCallSites = CreateArgumentCallSites( implementationType, callSiteChain, parameters, throwIfCallSiteNotFound: false); if (currentParameterCallSites != null) { if (bestConstructor == null) { bestConstructor = constructors[i]; parameterCallSites = currentParameterCallSites; } else { // Since we're visiting constructors in decreasing order of number of parameters, // we'll only see ambiguities or supersets once we've seen a 'bestConstructor'. //由于我们以参数数量递减的顺序访问构造函数, //只有在看到“最佳构造函数”后,我们才会看到歧义或超集。 if (bestConstructorParameterTypes == null) { bestConstructorParameterTypes = new HashSet<Type>(); foreach (ParameterInfo p in bestConstructor.GetParameters()) { bestConstructorParameterTypes.Add(p.ParameterType); } } foreach (ParameterInfo p in parameters) { if (!bestConstructorParameterTypes.Contains(p.ParameterType)) { // Ambiguous match exception throw new InvalidOperationException(string.Join( Environment.NewLine, SR.Format(SR.AmbiguousConstructorException, implementationType), bestConstructor, constructors[i])); } } } } } if (bestConstructor == null) { throw new InvalidOperationException( SR.Format(SR.UnableToActivateTypeException, implementationType)); } else { Debug.Assert(parameterCallSites != null); return new ConstructorCallSite(lifetime, serviceType, bestConstructor, parameterCallSites); } } finally { callSiteChain.Remove(serviceType); } }
看到这里,我们已经解决了两个问题:
- 具有多个构造函数的情况下默认选择使用哪一个构造函数
- 识别和解决循环依赖的问题
Singleton服务不能依赖Scoped服务,是如何校验的?我们回到刚才OnCreate的地方继续阅读。
private void OnCreate(ServiceCallSite callSite){ _callSiteValidator?.ValidateCallSite(callSite);}
ValidateCallSite方法,用于验证指定的ServiceCallSite对象是否正确,并将其中包含的作用域服务添加到_scopedServices字典中。
在ValidateCallSite方法中,我们首先使用VisitCallSite方法遍历整个ServiceCallSite对象,并返回其中所包含的作用域服务类型。如果ServiceCallSite对象中存在作用域服务,则将其添加到_scopedServices字典中,以便后续的依赖解析过程中使用。
public void ValidateCallSite(ServiceCallSite callSite){ Type scoped = VisitCallSite(callSite, default); if (scoped != null) { _scopedServices[callSite.ServiceType] = scoped; }}
ValidateCallSite存在VisitScopeCache方法,该方法首先判断当前ServiceCallSite对象是否是IServiceScopeFactory类型,如果是,则直接返回null。否则,我们检查state.Singleton属性是否为null,如果不为null,则说明当前ServiceCallSite对象属于单例服务,并且其中包含作用域服务的注入,此时将抛出InvalidOperationException异常,提示用户检查服务依赖关系是否正确;否则,我们继续递归遍历ServiceCallSite对象图。
protected override Type VisitScopeCache(ServiceCallSite scopedCallSite, CallSiteValidatorState state){ // We are fine with having ServiceScopeService requested by singletons if (scopedCallSite.ServiceType == typeof(IServiceScopeFactory)) { return null; } // ScopedInSingletonException异常! if (state.Singleton != null) { throw new InvalidOperationException(SR.Format(SR.ScopedInSingletonException, scopedCallSite.ServiceType, state.Singleton.ServiceType, nameof(ServiceLifetime.Scoped).ToLowerInvariant(), nameof(ServiceLifetime.Singleton).ToLowerInvariant() )); } VisitCallSiteMain(scopedCallSite, state); return scopedCallSite.ServiceType;}
获取服务
以上我们可以归纳为构建IServiceProvider,然后我们通过GetService()方法,看下如何获取服务。
从缓存中获取指定类型的服务,如果缓存中不存在,则调用_createServiceAccessor委托创建一个新的实例,并将其添加到缓存中
internal object GetService(Type serviceType, ServiceProviderEngineScope serviceProviderEngineScope){ // 使用了ConcurrentDictionary来缓存已经解析的服务 Func<ServiceProviderEngineScope, object> realizedService = _realizedServices.GetOrAdd(serviceType, _createServiceAccessor); OnResolve(serviceType, serviceProviderEngineScope); // 服务的实际实现 var result = realizedService.Invoke(serviceProviderEngineScope); return result;}
当缓存中不存在的时候,我们使用_createServiceAccessor创建一个新的实例(和上文获取callSite流程一致)。
private Func<ServiceProviderEngineScope, object> CreateServiceAccessor(Type serviceType){ // 取给定服务类型的CallSite对象 ServiceCallSite callSite = CallSiteFactory.GetCallSite(serviceType, new CallSiteChain()); if (callSite != null) { OnCreate(callSite); // 服务具有Singleton生命周期,可以优化处理,避免每次获取服务实例时都需要重新创建 if (callSite.Cache.Location == CallSiteResultCacheLocation.Root) { object value = CallSiteRuntimeResolver.Instance.Resolve(callSite, Root); return scope => value; } // 服务具有Transient或Scoped生命周期,需要创建并返回一个新的服务实例访问器 return _engine.RealizeService(callSite); } return _ => null;}
二、解决问题
1. Singleton服务依赖Scoped服务
创建一个生命周期为单例的SingletonService和另一个生命周期为作用域的ScopedService,SingletonService服务依赖ScopedService服务。就会报错:
Some services are not able to be constructed (Error while validating the service descriptor 'ServiceType: MyTest.Service.ISingletonService Lifetime: Singleton ImplementationType: MyTest.Service.SingletonService': Cannot consume scoped service 'MyTest.Service.IScopedService' from singleton 'MyTest.Service.ISingletonService'.)
解决方法
- 使用IServiceScopeFactory对象
public class SingletonService : ISingletonService{ private readonly IScopedService _scopedService; public SingletonService(IServiceScopeFactory serviceScopeFactory) { _scopedService = serviceScopeFactory.CreateScope().ServiceProvider.GetRequiredService<IScopedService>(); } }
- 使用IServiceProvider对象
public class SingletonService : ISingletonService{ private readonly IScopedService _scopedService; public SingletonService(IServiceProvider serviceProvider) { _scopedService = serviceProvider.CreateScope().ServiceProvider.GetRequiredService<IScopedService>(); } }
- 修改生命周期
保持生命周期一致,在修改生命周期时我们需要仔细分析服务的依赖关系和实现细节,并根据具体情况进行权衡和调整
2. 构造函数的选择逻辑
如果实现类没有构造函数,则抛出NoConstructorMatch异常。
如果实现类只有一个构造函数,判断该构造函数是否无参。如果是无参构造函数,则直接返回ConstructorCallSite;否则,对构造函数的参数创建对应的parameterCallSites,并返回ConstructorCallSite。
多个构造函数的逻辑
- 遍历所有构造函数,以参数数量递减的方式访问。
- 对于每个构造函数,判断其参数类型是否可以解析出来。
- 如果可以,将该构造函数与对应参数设置为“最佳构造函数”和“最佳参数”,并继续遍历其他构造函数。
- 若存在多个“最佳构造函数”则判断是否有歧义或超集,有则抛出AmbiguousConstructorException异常;否则,将“最佳构造函数”和“最佳参数”返回。
如果不是很理解选择逻辑,可以结合上文中的CreateConstructorCallSite方法,观看代码会更加直接,便于理解。
3. 解决循环依赖
参考:
http://misko.hevery.com/2008/08/01/circular-dependency-in-constructors-and-dependency-injection/
https://thomaslevesque.com/2020/03/18/lazily-resolving-services-to-fix-circular-dependencies-in-net-core/
我认为出现循环依赖,是我们代码结构设计有问题,根本解决方案是将依赖关系分解成更小的部分,从而避免出现循环依赖的情况,同时使个代码结构更加清晰、简单。
在这种情况下,真实原因是两个对象中的一个隐藏了另一个对象 C。A 包含 C 或 B 包含 C。我们假设B包含了C。
class A { final B b; A(B b){ this.b = b; }}class B { final A a; B(A a){ this.a = a; }}+---------+ +---------+| A |<-----| B || | | | +-+ || | | +->|C| || |------+---->| | || | | +-+ |+---------+ +---------+
我们将C单独抽出来,作为一个服务,让A和B都依赖于C,这样就可以解决循环依赖的问题。
+---------++---------+ | B || A |<-------------| || | | || | +---+ | || |--->| C |<----| || | +---+ +---------++---------+class C { C(){ }}class A { final C c; A(C c){ this.c = c; }}class B { final A a; final C c; B(A a, C c){ this.a = a; this.c = c; }}
- 使用 IServiceProvider 对象,GetRequiredService方法去获取实例
class C : IC{ private readonly IServiceProvider _services; public C(IServiceProvider services) { _services = services; } public void Bar() { ... var a = _services.GetRequiredService<IA>(); a.Foo(); ... }}
- 使用 Lazy
下边的方法我利用了Lazy类,需要添加一个 IServiceCollection 的扩展,新建一个静态类
public static IServiceCollection AddLazyResolution(this IServiceCollection services){ return services.AddTransient( typeof(Lazy<>), typeof(LazilyResolved<>));}private class LazilyResolved<T> : Lazy<T>{ public LazilyResolved(IServiceProvider serviceProvider) : base(serviceProvider.GetRequiredService<T>) { }}
然后再 Startup.cs 中的 ConfigureServices 方法中这样写
services.AddLazyResolution();
在依赖的类中IA,注入Lazy,当您需要使用时IA,只需访问lazy的值 Value 即可:
class C : IC{ private readonly Lazy<IA> _a; public C(Lazy<IA> a) { _a = a; } public void Bar() { ... _a.Value.Foo(); ... }}
注意:不要访问构造函数中的值,保存Lazy即可 ,在构造函数中访问该值,这将导致我们试图解决的相同问题。
这个解决方案不是完美的,但是它解决了最初的问题却没有太多麻烦,并且依赖项仍然在构造函数中明确声明,我可以看到类之间的依赖关系。
如果您觉得这篇文章有所收获,还请点个赞并关注。如果您有任何建议或意见,欢迎在评论区留言,非常感谢您的支持和指导!
猜你喜欢
联络方式:
400-123-789
邮箱:xiachao@163.com
Q Q:12345678