• 设为首页
  • 点击收藏
  • 手机版
    手机扫一扫访问
    迪恩网络手机版
  • 关注官方公众号
    微信扫一扫关注
    公众号

ASP.NET Core 6框架揭秘实例演示[06]:依赖注入框架设计细节

原作者: [db:作者] 来自: [db:来源] 收藏 邀请

由于依赖注入具有举足轻重的作用,所以《ASP.NET Core 6框架揭秘》的绝大部分章节都会涉及这一主题。本书第3章对.NET原生的依赖注入框架的设计和实现进行了系统的介绍,其中设计一些“鲜为人知”的细节,其中一部分就体现在本篇提供的这几个实例演示上。

[308]构造函数的选择(成功)(源代码
[309]构造函数的选择(失败)(源代码
[310]IDisposable和IAsyncDisposable接口的差异(错误编程)(源代码
[311]IDisposable和IAsyncDisposable接口的差异(错误编程)(源代码
[312]利用ActivatorUtilities提供服务实例(源代码
[313]ActivatorUtilities针对构造函数的“评分”(源代码
[314]ActivatorUtilities针对构造函数的选择(源代码
[315]ActivatorUtilities针对构造函数的选择(源代码
[316]与第三方依赖注入框架Cat的整合(源代码

[308]构造函数的选择(成功)

如果通过指定服务类型调用IServiceProvider对象的GetService方法,它总是会根据提供的服务类型从服务注册列表中找到对应的ServiceDescriptor对象,并根据它来提供所需的服务实例。ServiceDescriptor对象具有三种构建方式,分别对应服务实例三种提供方式。我们既可以提供一个Func<IServiceProvider, object>对象作为工厂来创建对应的服务实例,也可以直接提供一个创建好的服务实例。如果提供的是服务的实现类型,最终提供的服务实例将通过该类型的某个构造函数来创建,那么构造函数是通过什么策略被选择出来的?

如果IServiceProvider对象试图通过调用构造函数的方式来创建服务实例,传入构造函数的所有参数必须先被初始化,所以最终被选择的构造函数必须具备一个基本的条件,那就是IServiceProvider对象能够提供构造函数的所有参数。假设我们定义了如下四个服务接口(IFoo、IBar、IBaz和IQux)和对应的实现类型(Foo、Bar、Baz和Qux)。我们为Qux定义了三个构造函数,参数都定义成服务接口类型。为了确定最终选择哪个构造函数来创建目标服务实例,我们在构造函数执行时在控制台上输出相应的指示性文字。

public interface IFoo {}
public interface IBar {}
public interface IBaz {}
public interface IQux {}

public class Foo : IFoo {}
public class Bar : IBar {}
public class Baz : IBaz {}
public class Qux : IQux
{
    public Qux(IFoo foo) => Console.WriteLine("Selected constructor: Qux(IFoo)");
    public Qux(IFoo foo, IBar bar) => Console.WriteLine("Selected constructor: Qux(IFoo, IBar)");
    public Qux(IFoo foo, IBar bar, IBaz baz) => Console.WriteLine("Selected constructor: Qux(IFoo, IBar, IBaz)");
}

我们在如下所示的演示程序创建了一个ServiceCollection对象,并在其中添加针对IFoo、IBar及IQux接口的服务注册,但针对IBaz接口的服务注册并未添加。当利用构建的IServiceProvider来提供针对IQux接口的服务实例时,我们是否能够得到一个Qux对象呢?如果可以,它又是通过执行哪个构造函数创建的呢?

using App;
using Microsoft.Extensions.DependencyInjection;

new ServiceCollection()
    .AddTransient<IFoo, Foo>()
    .AddTransient<IBar, Bar>()
    .AddTransient<IQux, Qux>()
    .BuildServiceProvider()
    .GetServices<IQux>();

对于定义在Qux中的三个构造函数来说, 由于存在针对IFoo和IBar接口的服务注册,所前面两个构造函数的所有参数能够由容器提供,第三个构造函数的bar参数却不能。根据前面介绍的第一个原则(IServiceProvider对象能够提供构造函数的所有参数),Qux的前两个构造函数会成为合法的候选构造函数,那么最终会选择哪一个构造函数呢?在所有合法的候选构造函数列表中,最终被选择的构造函数具有如下特征:所有候选构造函数的参数类型都能在这个构造函数中找到。如果这样的构造函数并不存在,会直接抛出一个InvalidOperationException类型的异常。根据这个原则,Qux的第二个构造函数的参数类型包括IFoo和IBar两个接口,而第一个构造函数只具有一个类型为IFoo的参数,所以最终被选择的是Qux的第二个构造函数,运行实例程序,控制台上产生的输出结果如图1所示。


图1 构造函数的选择策略

[309]构造函数的选择(失败)

我们接下来只为Qux类型定义两个构造函数,它们都具有两个参数,参数类型分别为IFoo & IBar和IBar & IBaz,我们同时将针对IBaz/Baz的服务注册添加到创建的ServiceCollection集合中。

using App;
using Microsoft.Extensions.DependencyInjection;

new ServiceCollection()
    .AddTransient<IFoo, Foo>()
    .AddTransient<IBar, Bar>()
    .AddTransient<IBaz, Baz>()
    .AddTransient<IQux, Qux>()
    .BuildServiceProvider()
    .GetServices<IQux>();

public class Qux : IQux
{
    public Qux(IFoo foo, IBar bar) {}
    public Qux(IBar bar, IBaz baz) {}
}

虽然Qux的两个构造函数的参数都可以由IServiceProvider对象来提供,但是并没有某个构造函数拥有所有候选构造函数的参数类型,所以选择一个最佳的构造函数。运行该程序后会抛出图2所示的InvalidOperationException类型的异常,并提示无法从两个候选的构造函数中选择一个最优的来创建服务实例。


图2 构造函数的选择策略

[310]IDisposable和IAsyncDisposable接口的差异(错误编程)

IServiceProvider对象除了提供所需的服务实例,它还需要负责在其生命周期终结的时候释放它们(如果需要的话)。这里所说的回收释放与 .NET的垃圾回收机制无关,仅仅针对自身类型实现了IDisposable或者IAsyncDisposable接口的服务实例(下面称为Disposable服务实例),具体的释放操作体现为调用它们的Dispose或者DisposeAsync方法。是当IServiceScope对象的Dispose方法被执行的时候,如果待释放服务实例对应的类型仅仅实现了IAsyncDisposable接口,而没有实现IDisposable接口,此时会抛出一个InvalidOperationException异常。

using Microsoft.Extensions.DependencyInjection;

using var scope = new ServiceCollection()
            .AddScoped<Fooar>()
            .BuildServiceProvider()
            .CreateScope();
scope.ServiceProvider.GetRequiredService<Fooar>();

public class Fooar : IAsyncDisposable
{
    public ValueTask DisposeAsync() => default;
}

如上面的代码片段所示,以Scoped模式注册的Foobar类型实现了IAsyncDisposable接口。我们在一个创建的服务范围内创建该服务实例之后,如图3所示的InvalidOperationException异常会在服务范围被释放的时候抛出来。

图3 IAsyncDisposable实例按照同步方式释放时抛出的异常

[311]IDisposable和IAsyncDisposable接口的差异(错误编程)

不论采用怎样的生命周期模式,服务实例的释放总是在容器被释放时完成的。容器的释放具有同步和异步两种形式,并由对应的服务范围来决定。以异步方式释放容器可以采用同步的方式释放服务实例,反之则不成立。如果服务类型只实现了IAsyncDisposable接口,意味着我们只能采用异步的方式释放容器,这正是图3-11所示的异常消息试图表达的意思。在这种情况下,我们应该按照如下的方式创建代表异步服务范围的AsyncServiceScope对象,并调用DisposeAsync方法(await using)以异步的方式释放容器。

using Microsoft.Extensions.DependencyInjection;

await using var scope = new ServiceCollection()
    .AddScoped<Fooar>()
    .BuildServiceProvider()
    .CreateAsyncScope();
scope.ServiceProvider.GetRequiredService<Fooar>();

[312]利用ActivatorUtilities提供服务实例

IServiceProvider对象能够提供指定类型服务实例的前提存在对应的服务注册,但是有的时候我们需要利用容器创建一个对应类型不曾注册的实例。一个最为典型的例子是MVC应用针对目标Controller实例的创建,因为Controller类型并未作为依赖服务进行注册。这种情况我们就会使用到ActivatorUtilities这个静态的工具类型。当我们调用定义在ActivatorUtilities类型中的如下这些静态方法根据指定的IServiceProvider对象创建指定服务实例时,虽然不要求针对目标服务被预先注册,但是要求指定的IServiceProvider对象能够提供构造函数中必要的参数。

public static class ActivatorUtilities
{
    public static object CreateInstance(IServiceProvider provider,  Type instanceType, params object[] parameters);
    public static T CreateInstance<T>(IServiceProvider provider, params object[] parameters);

    public static object GetServiceOrCreateInstance(IServiceProvider provider, Type type);
    public static T GetServiceOrCreateInstance<T>(IServiceProvider provider);
}

如下的程序演示了ActivatorUtilities的典型用法。如代码片段所示,Foobar类型的构造函数除了注入Foo和Bar这两个可以由容器提供的对象之外,还包含一个用来初始化Name属性的字符串类型的参数。我们将IServiceProvider对象作为参数调用ActivatorUtilities的CreateInstance<T>方法创建一个Foobar对象,此时构造函数的第一个name参数必须显式指定。

using Microsoft.Extensions.DependencyInjection;
using System.Diagnostics;

var serviceProviderr = new ServiceCollection()
    .AddSingleton<Foo>()
    .AddSingleton<Bar>()
    .BuildServiceProvider();
var foobar = ActivatorUtilities.CreateInstance<Foobar>(serviceProviderr, "foobar");
Debug.Assert(foobar.Name == "foobar");

public class Foo { }
public class Bar { }
public class Foobar
{
    public string Name { get; }
    public Foo Foo { get; }
    public Bar Bar { get; }

    public Foobar(string name, Foo foo, Bar bar)
    {
        Name = name;
        Foo = foo;
        Bar = bar;
    }
}

[313]ActivatorUtilities针对构造函数的“评分”

当我们调用ActivatorUtilities类型的CreateInstance方法创建指定类型的实例时,它总是会选择选择一个“适合”的构造函数。前面我们详细讨论过依赖注入容器对构造函数的选择策略,那么这里的构造函数又是如何被选择出来的呢?如果目标类型定义了多个候选的公共构造函数,最终哪一个被选择取决于两个因素:显式指定的参数列表和构造函数被定义顺序。具体来说,它会遍历每一个候选的公共构造函数,并针对它们创建具有如下定义的ConstructorMatcher对象,然后将我们显式指定的参数列表作为参数调用其Match方法,该方法返回的数字表示当前构造函数与指定的参数列表的匹配度。值越大,意味着匹配度越高,-1表示完全不匹配。

public static class ActivatorUtilities
{
    private struct ConstructorMatcher
    {
        public ConstructorMatcher(ConstructorInfo constructor);
        public int Match(object[] givenParameters);
    }
}

ActivatorUtilities最终会选择匹配度不小于零且值最高的那个构造函数。如果多个构造函数同时拥有最高匹配度,遍历的第一个构造函数会被选择。我个人其实不太认可这样的设计,既然匹配度相同,对应的构造函数就应该是平等的,为了避免错误的构造函数被选择,抛出异常可能是更好的选择。

对于根据构造函数创建的ConstructorMatcher对象来说,它的Match方法相当于为候选的构造函数针对当前调用场景打了一个匹配度分值,那么这个得分是如何计算的呢?具体的计算流程基本上体现在图4中。假设构造函数参数类型依次为Foo、Bar和Baz,如果显式指定的参数列表的某一个与这三个类型都不匹配,比如指定了一个Qux对象,并且Qux类型没有继承这三个类型中的任何一个,此时的匹配度得分就是-1。

图4 构造函数针对参数数组的匹配度

如果指定的N个参数都与构造函数的前N个参数匹配得上,那么最终的匹配度得分就是N-1。假设foo、bar和baz分别为代码类型为Foo、Bar和Baz的对象,那么只有三种匹配场景,即提供的参数分别为[foo]、[foo, bar]和[foo,bar, baz],最终的匹配度得分分别为0、1和2。如果指定的参数数组不能满足上述的严格匹配规则,最终的得分就是0。为了验证构造函数匹配规则,我们来做一个简单的示例演示。如下面的代码片段所示,我们定义了一个Foobarbaz类型,它的构造函数的参数类型依次为Foo、Bar和Baz。我们采用了反射的方式创建了针对这个构造函数的ConstructorMatcher对象。对于给出的几种参数序列,我们调用ConstructorMatcher对象的Match方法计算该构造函数与它们的匹配度。

using Microsoft.Extensions.DependencyInjection;
using System.Reflection;

var constructor = typeof(Foobarbaz).GetConstructors().Single();
var matcherType = typeof(ActivatorUtilities).GetNestedType("ConstructorMatcher", BindingFlags.NonPublic) ?? throw new InvalidOperationException("It fails to resove ConstructorMatcher type");
var matchMethod = matcherType.GetMethod("Match");

var foo = new Foo();
var bar = new Bar();
var baz = new Baz();
var qux = new Qux();

Console.WriteLine($"[Qux] = {Match(qux)}");

Console.WriteLine($"[Foo] = {Match(foo)}");
Console.WriteLine($"[Foo, Bar] = {Match(foo, bar)}");
Console.WriteLine($"[Foo, Bar, Baz] = {Match(foo, bar, baz)}");

Console.WriteLine($"[Bar, Baz] = {Match(bar, baz)}");
Console.WriteLine($"[Foo, Baz] = {Match(foo, baz)}");


int? Match(params object[] args)
{
    var matcher = Activator.CreateInstance(matcherType, constructor);
    return (int?)matchMethod?.Invoke(matcher, new object[] { args });
}
public class Foo {}
public class Bar {}
public class Baz {}
public class Qux {}

public class Foobarbaz
{
    public Foobarbaz(Foo foo, Bar bar, Baz baz) { }
}

演示程序执行之后会在控制台上输出如图5所示的结果。对于第一个测试结果,由于我们指定了一个Qux对象,它与构造函数的任一个参数都不兼容,所以匹配度为-1。接下来的三个参数组合完全符合上述的匹配规则,所以得到的匹配度得分为N-1(0、1和2)。至于其他两个,[Bar, Baz]虽然与构造函数的后两个参数兼容(包括顺序),由于Match方法从第一个参数进行匹配,得分依然是0。最后一个组合[Foo, Baz]由于漏掉一个,同样得零分。


图5 测试同一构造函数针对不同参数组合的匹配度

[314]ActivatorUtilities针对构造函数的选择

我不确定构造函数选择策略在今后的版本中会不会修改,就目前的设计来说,我是不认同的。我觉得这样的选择策略是不严谨的,就上面的演示实例验证的构造函数来说,对于参数组合[Foo, Bar]和[Bar, Foo],以及[Foo, Bar]和[Bar, Baz],我不觉得它们在匹配程度上有什么不同。这样的策略还会带来另一个问题,那就是最终被选择的构造函数不仅仅依赖于指定的参数组合,还决定于候选构造函数在所在类型中被定义的顺序。

using Microsoft.Extensions.DependencyInjection;

var serviceProvider = new ServiceCollection()
    .AddSingleton<Foo>()
    .AddSingleton<Bar>()
    .AddSingleton<Baz>()
    .BuildServiceProvider();

ActivatorUtilities.CreateInstance<Foobar>(serviceProvider);
ActivatorUtilities.CreateInstance<BarBaz>(serviceProvider);

public class Foo {}
public class Bar {}
public class Baz {}

public class Foobar
{
    public Foobar(Foo foo) => Console.WriteLine("Foobar(Foo foo)");
    public Foobar(Foo foo, Bar bar) => Console.WriteLine("Foobar(Foo foo, Bar bar)");
}
public class BarBaz
{
    public BarBaz(Bar bar, Baz baz) => Console.WriteLine("BarBaz(Bar bar, Baz baz)");
    public BarBaz(Bar bar) => Console.WriteLine("BarBaz(Bar bar)");
}

以如上的演示程序为例,Foobar和Barbaz都具有两个构造函数,参数数量分别为1和2,不同的是Foobar中包含一个参数的构造函数被放在前面,而Barbaz则将其置于后面。当我们调用ActivatorUtilities的CreateInstance<T>构造函数分别创建Foobar和Barbaz对象的时候,总是第一个构造函数被执行(如图6所示)。这意味着当我们无意中改变了构造函数的定义顺序就会改变应用程序执行的行为,这在我看来是不能接受的。


图6 选择的构造函数与定义顺序有关

[315]ActivatorUtilities针对构造函数的选择

默认的构造函数选择策略过于模糊且不严谨,如果希望ActivatorUtilities选择某个构造函数,我们可以通过在目标构造函数上标注ActivatorUtilitiesConstructorAttribute特性的方式来解决这个问题。就上面这个实例来说,如果我们希望ActivatorUtilities选择FooBar具有两个参数的构造函数,可以按照如下的方式在该构造函数上面标注ActivatorUtilitiesConstructorAttribute特性。

public class Foobar
{
    public Foobar(Foo foo) => Console.WriteLine("Foobar(Foo foo)");

    [ActivatorUtilitiesConstructor]
    public Foobar(Foo foo, Bar bar) => Console.WriteLine("Foobarbaz(Foo foo, Bar bar)");
}

[316]与第三方依赖注入框架Cat的整合

我们在第2章“依赖注入(上)”中创建了一个名为Cat的依赖注入框架,我们接下来就通过上述的方式将它引入到应用中。我们首选创建一个名为CatBuilder的类型作为对应的ContainerBuilder。由于需要涉及针对服务范围的创建,我们在CatBuilder类中定义了如下两个内嵌的私有类型。表示服务范围的ServiceScope对象实际上就是对一个IServiceProvider对象的封装,而ServiceScopeFactory类型为创建它的工厂,它是对一个Cat对象的封装。

public class CatBuilder
{
    private class ServiceScope : IServiceScope
    {
        public ServiceScope(IServiceProvider serviceProvider) => ServiceProvider = serviceProvider;
        public IServiceProvider ServiceProvider { get; }
        public void Dispose()=> (ServiceProvider as IDisposable)?.Dispose();
    }

    private class ServiceScopeFactory : IServiceScopeFactory
    {
        private readonly Cat _cat;
        public ServiceScopeFactory(Cat cat) => _cat = cat;
        public IServiceScope CreateScope() => new ServiceScope(_cat);
    }
}

一个CatBuilder对象是对一个Cat对象的封装,它的BuildServiceProvider方法会直接返回这个Cat对象,并将它作为最终构建的依赖注入容器。CatBuilder对象在初始化过程中添加了针对IServiceScopeFactory/ServiceScopeFactory的服务注册。为了实现程序集范围内的批量服务注册,我们为CatBuilder类型定义一个Register方法。

public class CatBuilder
{
    private readonly Cat _cat;
    public CatBuilder(Cat cat)
    {
        _cat = cat;
        _cat.Register<IServiceScopeFactory>(c => new ServiceScopeFactory(c.CreateChild()), Lifetime.Transient);
    }
    public IServiceProvider BuildServiceProvider() => _cat;
    public CatBuilder Register(Assembly assembly)
    {
        _cat.Register(assembly);
        return this;
    }
    ...
}

如下面的代码片段所示,CatServiceProviderFactory类型实现了IServiceProviderFactory<CatBuilder>接口。在实现的CreateBuilder方法中,我们创建了一个Cat对象,并将IServiceCollection集合包含的服务注册(ServiceDescriptor对象)转换成Cat的服务注册形式(ServiceRegistry对象)。在将转换后的服务注册应用到Cat对象上之后,我们最终利用这个Cat对象创建出返回的CatBuilder对象。在实现的CreateServiceProvider方法中,我们直接返回调用CatBuilder对象的CreateServiceProvider方法得到的IServiceProvider对象。

public class CatServiceProviderFactory : IServiceProviderFactory<CatBuilder>
{
    public CatBuilder CreateBuilder(IServiceCollection services)
    {
        var cat = new Cat();
        foreach (var service in services)
        {
            if (service.ImplementationFactory != null)
            {
                cat.Register(service.ServiceType, provider => service.ImplementationFactory(provider), service.Lifetime.AsCatLifetime());
            }
            else if (service.ImplementationInstance != null)
            {
                cat.Register(service.ServiceType, service.ImplementationInstance);
            }
            else
            {
                cat.Register(service.ServiceType, service.ImplementationType, service.Lifetime.AsCatLifetime());
            }
        }
        return new CatBuilder(cat);
    }
    public IServiceProvider CreateServiceProvider(CatBuilder containerBuilder) => containerBuilder.BuildServiceProvider();
}

对于服务实例的生命周期模式,Cat与 .NET依赖注入框架具有一致的表达,所以在将服务注册从ServiceDescriptor类型转化成ServiceRegistry类型时,我们可以简单的完成两者的转换。具体的转换实现如下所示的AsCatLifetime扩展方法中。

internal static class Extensions
{
    public static Lifetime AsCatLifetime(this ServiceLifetime lifetime)
    {
        return lifetime switch
        {
            ServiceLifetime.Scoped => Lifetime.Self,
            ServiceLifetime.Singleton => Lifetime.Root,
            _ => Lifetime.Transient,
        };
    }
}

我们接下来演示如何利用CatServiceProviderFactory创建作为依赖注入容器的IServiceProvider对象。我们定义了Foo、Bar、Baz和Qux四个类型和它们实现的IFoo、IBar、IBaz与IQux接口。Qux类型上标注了一个MapToAttribute特性,并注册了与对应接口IQux之间的映射。这些类型派生的基类Base实现了IDisposable接口,我们在其构造函数和实现的Dispose方法中输出相应的文本,以确定实例被创建和释放的时机。

public interface IFoo {}
public interface IBar {}
public interface IBaz {}
public interface IQux {}
public interface IFoobar<T1, T2> {}
public class Base : IDisposable
{
    public Base() => Console.WriteLine($"Instance of {GetType().Name} is created.");
    public void Dispose() => Console.WriteLine($"Instance of {GetType().Name} is disposed.");
}

public class Foo : Base, IFoo{ }
public class Bar : Base, IBar{ }
public class Baz : Base, IBaz{ }
[MapTo(typeof(IQux), Lifetime.Root)]
public class Qux : Base, IQux { }
public class Foobar<T1, T2>: IFoobar<T1,T2>
{
    public IFoo Foo { get; }
    public IBar Bar { get; }
    public Foobar(IFoo foo, IBar bar)
    {
        Foo = foo;
        Bar = bar;
    }
}

在如下所示的演示程序中,我们先创建了一个ServiceCollection集合,并采用三种不同的生命周期模式分别添加了针对IFoo、IBar和IBaz接口的服务注册。我们接下来根据ServiceCollection集合创建了一个CatServiceProviderFactory工厂,并调用其CreateBuilder方法创建出对应的CatBuilder对象。我们最后调用CatBuilder对象的Register方法完成了针对当前入口程序集的批量服务注册,其目的在于添加针对IQux/Qux的服务注册。

using App;
using Microsoft.Extensions.DependencyInjection;

var services = new ServiceCollection()
            .AddTransient<IFoo, Foo>()
            .AddScoped<IBar>(_ => new Bar())
            .AddSingleton<IBaz>(new Baz());

var factory = new CatServiceProviderFactory();
var builder = factory.CreateBuilder(services).Register(typeof(Foo).Assembly);
var container = factory.CreateServiceProvider(builder);

GetServices();
GetServices();
Console.WriteLine("\nRoot container is disposed.");
(container as IDisposable)?.Dispose();

void GetServices()
{
    using var scope = container.CreateScope();
    Console.WriteLine("\nService scope is created.");
    var child = scope.ServiceProvider;

    child.GetService<IFoo>();
    child.GetService<IBar>();
    child.GetService<IBaz>();
    child.GetService<IQux>();

    child.GetService<IFoo>();
    child.GetService<IBar>();
    child.GetService<IBaz>();
    child.GetService<IQux>();
    Console.WriteLine("\nService scope is disposed.");
}

在调用CatServiceProviderFactory工厂的CreateServiceProvider方法来创建出作为依赖注入容器的IServiceProvider对象之后,我们先后两次调用了本地方法GetServices,后者会利用这个IServiceProvider对象来创建一个服务范围,并利用此服务范围内的IServiceProvider提供两组服务实例。利用CatServiceProviderFactory创建的IServiceProvider对象最终通过调用其Dispose方法进行释放。该程序运行之后在控制台上输出的结果如图7所示,输出结果体现的服务生命周期与演示程序体现的生命周期是完全一致的。

图7 利用CatServiceProviderFactory创建IServiceProvider对象


鲜花

握手

雷人

路过

鸡蛋
该文章已有0人参与评论

请发表评论

全部评论

专题导读
上一篇:
ASP.NET分页存储过程自定义用户控件发布时间:2022-07-10
下一篇:
ASP.NET学习路线图[转]发布时间:2022-07-10
热门推荐
热门话题
阅读排行榜

扫描微信二维码

查看手机版网站

随时了解更新最新资讯

139-2527-9053

在线客服(服务时间 9:00~18:00)

在线QQ客服
地址:深圳市南山区西丽大学城创智工业园
电邮:jeky_zhao#qq.com
移动电话:139-2527-9053

Powered by 互联科技 X3.4© 2001-2213 极客世界.|Sitemap