在使用 Autofac 框架进行开发后,编写集成测试时,需要用 Mock 的用于测试的模拟的类型去代替容器里面已注入的实际类型,也就需要在 Autofac 完全收集完成之后,再次注入模拟的对象进行覆盖原有业务代码注册的正式对象。但 Autofac 默认没有提供此机制,我阅读了 Autofac 的源代码之后,创建了一些辅助代码,实现了此功能。本文将告诉大家如何在集成测试里面,在使用了 Autofac 的项目里面,在所有收集完成之后,注入用于测试的 Mock 类型,和 Autofac 接入的原理
背景
为什么选择使用 Autofac 框架?原因是在此前的 WPF 项目里面,有使用过的是 MEF 和 Autofac 两个框架,而 MEF 的性能比较糟心。解决 MEF 性能问题的是 VS-MEF 框架。在后续开发的一个 ASP.NET Core 项目里面,也就自然选用了 Autofac 框架
对比原生的 ASP.NET Core 自带的 DI 框架,使用 Autofac 的优势在于支持模块化的初始化,支持属性注入
默认的 Autofac 可以通过 Autofac.Extensions.DependencyInjection
将 Autofac 和 dotnet 通用依赖注入框架合入在一起,但在 Autofac 里面的定制要求是在 Startup 的 ConfigureContainer 函数里面进行依赖注入,也就是在默认的 ASP.NET Core 里面没有提供更靠后的依赖注入方法,可以在完成收集之后,再次注入测试所需要的类型,覆盖业务代码里面的实际对象
需求
假定在一个应用,如 ASP.NET Core 应用里面,进行集成测试,想要在集成测试里面,使用项目里面的依赖注入关系,只是将部分类型替换为测试项目里面的模拟的类型
而在应用里面,实际的业务类型是在 Autofac 的 Module 进行注入的。在应用里面的大概逻辑如下,在 Program 的 CreateHostBuilder 函数里面通过 UseServiceProviderFactory 方法使用 Autofac 替换 原生 的框架
public static IHostBuilder CreateHostBuilder(string[] args) => Host.CreateDefaultBuilder(args) .ConfigureWebHostDefaults(webBuilder => { webBuilder.UseStartup<Startup>(); }) // 使用 auto fac 代替默认的 IOC 容器 .UseServiceProviderFactory(new AutofacServiceProviderFactory());
在 Startup 里面添加 ConfigureContainer 方法,代码如下
public void ConfigureContainer(ContainerBuilder builder) { }
在 ConfigureContainer 里面注入具体的需要初始化的业务模块,例如 FooModule 模块。在具体模块里面注入实际业务的类型,如 Foo 类型,代码如下
public class Startup { // 忽略代码 public void ConfigureContainer(ContainerBuilder builder) { builder.RegisterModule(new FooModule()); } } class FooModule : Autofac.Module { protected override void Load(ContainerBuilder builder) { Console.WriteLine($"06 FooModule"); builder.RegisterType<Foo>().As<IFoo>(); } } public class Foo : IFoo { } public interface IFoo { }
现在的需求是在集成测试里面,将 IFoo 的实际类型从 Foo 替换为 TestFoo 类型
在集成测试项目里面,可以使用如下代码获取实际的项目的依赖注入收集
var hostBuilder = Host.CreateDefaultBuilder() .ConfigureWebHostDefaults(webBuilder => { webBuilder.UseStartup<Startup>(); webBuilder.UseTestServer(); //关键是多了这一行建立TestServer }) // 使用 auto fac 代替默认的 IOC 容器 .UseServiceProviderFactory(new AutofacServiceProviderFactory()); var host = hostBuilder.Build(); var foo = host.Services.GetService<IFoo>();
以上的 foo 就是从收集的容器里面获取的 IFoo 对象,以上代码获取到的是业务代码的 Foo 类型对象。假定需要让容器里面的 IFoo 的实际类型作为测试的 TestFoo 类型,就需要在实际项目的依赖注入收集完成之前,进行测试的注入
但实际上没有在 Autofac 里面找到任何的辅助方法可以用来实现此功能。如果是默认的应用框架,可以在 ConfigureWebHostDefaults 函数之后,通过 ConfigureServices 函数覆盖在 Startup 的 ConfigureServices 函数注入的类型
实现方法
实现的方法是很简单的,关于此实现为什么能解决问题还请参阅下文的原理部分
集成测试项目不需要改动原有的业务项目即可完成测试,实现方法是在集成测试项目里面添加 FakeAutofacServiceProviderFactory 用来替换 Autofac 的 AutofacServiceProviderFactory 类型,代码如下
class FakeAutofacServiceProviderFactory : IServiceProviderFactory<ContainerBuilder> { public FakeAutofacServiceProviderFactory( ContainerBuildOptions containerBuildOptions = ContainerBuildOptions.None, Action<ContainerBuilder>? configurationActionOnBefore = null, Action<ContainerBuilder>? configurationActionOnAfter = null) { _configurationActionOnAfter = configurationActionOnAfter; AutofacServiceProviderFactory = new AutofacServiceProviderFactory(containerBuildOptions, configurationActionOnBefore); } private AutofacServiceProviderFactory AutofacServiceProviderFactory { get; } private readonly Action<ContainerBuilder>? _configurationActionOnAfter; public ContainerBuilder CreateBuilder(IServiceCollection services) { return AutofacServiceProviderFactory.CreateBuilder(services); } public IServiceProvider CreateServiceProvider(ContainerBuilder containerBuilder) { _configurationActionOnAfter?.Invoke(containerBuilder); return AutofacServiceProviderFactory.CreateServiceProvider(containerBuilder); } }
可以看到本质的 FakeAutofacServiceProviderFactory 实现就是通过 AutofacServiceProviderFactory 的属性实现,只是在 CreateServiceProvider 方法里面加入了委托,可以方便在单元测试里面进行注入自己的方法
在集成测试项目里面的使用方法如下
var hostBuilder = Host.CreateDefaultBuilder() .ConfigureWebHostDefaults(webBuilder => { webBuilder.UseStartup<Startup>(); webBuilder.UseTestServer(); //关键是多了这一行建立TestServer }) // 使用 auto fac 代替默认的 IOC 容器 .UseServiceProviderFactory(new FakeAutofacServiceProviderFactory(configurationActionOnAfter: builder => { builder.RegisterModule<TestModule>(); }));
传入的委托需要注入测试的初始化模块,也就是 TestModule 需要加入注入,通过上面代码,可以让 TestModule 在依赖注入的最后进行初始化。在 TestModule 里面加入实际的测试类型注入的代码
class TestModule : Autofac.Module { protected override void Load(ContainerBuilder builder) { builder.RegisterType<TestFoo>().As<IFoo>(); } } class TestFoo : IFoo { }
通过上面方法就可以让集成测试项目从容器里面获取 IFoo 的对象,拿到的是 TestFoo 类型,集成测试项目的代码如下
[TestClass] public class FooTest { [ContractTestCase] public void Test() { "依赖注入的时机,可以在完成收集之后,覆盖原有的类型".Test(() => { var hostBuilder = Host.CreateDefaultBuilder() .ConfigureWebHostDefaults(webBuilder => { webBuilder.UseStartup<Startup>(); webBuilder.UseTestServer(); //关键是多了这一行建立TestServer }) // 使用 auto fac 代替默认的 IOC 容器 .UseServiceProviderFactory(new FakeAutofacServiceProviderFactory(configurationActionOnAfter: builder => { builder.RegisterModule<TestModule>(); })); var host = hostBuilder.Build(); var foo = host.Services.GetService<IFoo>(); Assert.IsInstanceOfType(foo, typeof(TestFoo)); }); } } class TestModule : Autofac.Module { protected override void Load(ContainerBuilder builder) { builder.RegisterType<TestFoo>().As<IFoo>(); } } class TestFoo : IFoo { } class FakeAutofacServiceProviderFactory : IServiceProviderFactory<ContainerBuilder> { public FakeAutofacServiceProviderFactory( ContainerBuildOptions containerBuildOptions = ContainerBuildOptions.None, Action<ContainerBuilder>? configurationActionOnBefore = null, Action<ContainerBuilder>? configurationActionOnAfter = null) { _configurationActionOnAfter = configurationActionOnAfter; AutofacServiceProviderFactory = new AutofacServiceProviderFactory(containerBuildOptions, configurationActionOnBefore); } private AutofacServiceProviderFactory AutofacServiceProviderFactory { get; } private readonly Action<ContainerBuilder>? _configurationActionOnAfter; public ContainerBuilder CreateBuilder(IServiceCollection services) { return AutofacServiceProviderFactory.CreateBuilder(services); } public IServiceProvider CreateServiceProvider(ContainerBuilder containerBuilder) { _configurationActionOnAfter?.Invoke(containerBuilder); return AutofacServiceProviderFactory.CreateServiceProvider(containerBuilder); } }
以上集成测试使用了 CUnit 中文单元测试框架辅助,在上面代码里面,可以看到集成测试里面的容器拿到的 IFoo 对象就是 TestFoo 类型。通过这个方法就可以在业务代码执行过程,注入测试需要的类型
为什么通过以上的代码即可实现此功能,为什么需要自己实现一个 FakeAutofacServiceProviderFactory 类型,为什么不能在 AutofacServiceProviderFactory.CreateServiceProvider 方法之前注入类型,而是需要再定义一个 TestModule 模块,在测试初始化模块进行初始化。更多细节请看下文
原理
回答以上问题,需要了解各个注入方法调用的顺序,我在代码里面通过控制台输出各个方法的顺序。标记了顺序的代码放在本文最后
以下是按照调用顺序的方法代码
Startup 的 ConfigureServices 方法
public class Startup { // 忽略代码 // This method gets called by the runtime. Use this method to add services to the container. // For more information on how to configure your application, visit https://go.microsoft.com/fwlink/?LinkID=398940 public void ConfigureServices(IServiceCollection services) { Console.WriteLine($"01 ConfigureServices"); } }
在 Startup 的 ConfigureServices 是依赖注入中最开始调用的方法,这也是原生的框架自带的方法
IHostBuilder 的 ConfigureServices 扩展方法
var hostBuilder = Host.CreateDefaultBuilder() .ConfigureWebHostDefaults(webBuilder => { webBuilder.UseStartup<Startup>(); webBuilder.UseTestServer(); //关键是多了这一行建立TestServer }) .ConfigureServices(collection => Console.WriteLine($"02 ConfigureServices Delegate"))
在 IHostBuilder 的 ConfigureServices 扩展方法将会在 Startup 的 ConfigureServices 方法执行完成之后调用,因此如果只使用原生的依赖注入,可以在此方法进行覆盖,也就是如果没有使用 Autofac 框架,只使用原生的框架,可以在集成测试,在此方法注入测试的类型
Startup 的 ConfigureContainer 方法
public class Startup { // 忽略代码 public void ConfigureContainer(ContainerBuilder builder) { Console.WriteLine($"03 ConfigureContainer"); builder.RegisterModule(new FooModule()); } }
可以看到 public void ConfigureContainer(ContainerBuilder builder)
方法的调用在 ConfigureServices 方法之后,在 Autofac 也通过此机制实现代替原生的依赖注入功能,也通过此方法从原生的注入获取依赖
关于 Autofac 的实际逻辑,请参阅下文
FakeAutofacServiceProviderFactory 的 CreateServiceProvider 方法
在如上代码,咱编写了 FakeAutofacServiceProviderFactory 用于替换 AutofacServiceProviderFactory 类型。在 FakeAutofacServiceProviderFactory 的 CreateServiceProvider 方法将会在调用 ConfigureContainer 之后执行
class FakeAutofacServiceProviderFactory : IServiceProviderFactory<ContainerBuilder> { public FakeAutofacServiceProviderFactory( ContainerBuildOptions containerBuildOptions = ContainerBuildOptions.None, Action<ContainerBuilder>? configurationActionOnBefore = null, Action<ContainerBuilder>? configurationActionOnAfter = null) { _configurationActionOnAfter = configurationActionOnAfter; AutofacServiceProviderFactory = new AutofacServiceProviderFactory(containerBuildOptions, configurationActionOnBefore); } private AutofacServiceProviderFactory AutofacServiceProviderFactory { get; } private readonly Action<ContainerBuilder>? _configurationActionOnAfter; public ContainerBuilder CreateBuilder(IServiceCollection services) { return AutofacServiceProviderFactory.CreateBuilder(services); } public IServiceProvider CreateServiceProvider(ContainerBuilder containerBuilder) { Console.WriteLine($"04 FakeAutofacServiceProviderFactory"); _configurationActionOnAfter?.Invoke(containerBuilder); return AutofacServiceProviderFactory.CreateServiceProvider(containerBuilder); } }
在以上的 CreateServiceProvider 方法将会在 Startup 的 ConfigureContainer 方法之后执行,如上面代码。按照上面代码,将会执行 _configurationActionOnAfter
委托
因此下一个执行的就是传入的委托
IHostBuilder? hostBuilder = Host.CreateDefaultBuilder() .ConfigureWebHostDefaults(webBuilder => { webBuilder.UseStartup<Startup>(); webBuilder.UseTestServer(); //关键是多了这一行建立TestServer }) .ConfigureServices(collection => Console.WriteLine($"02 ConfigureServices Delegate")) // 使用 auto fac 代替默认的 IOC 容器 .UseServiceProviderFactory(new FakeAutofacServiceProviderFactory(configurationActionOnAfter: builder => { Console.WriteLine($"05 ConfigurationActionOnAfter"); builder.RegisterModule<TestModule>(); }));
也就是如上代码的 ConfigurationActionOnAfter 委托
但尽管 FakeAutofacServiceProviderFactory 的 CreateServiceProvider 在 Startup 的 ConfigureContainer 方法之后执行,实际上很多开发者不会在 Startup 的 ConfigureContainer 方法完成注册,而是在 ConfigureContainer 里面初始化模块。如上面代码,在业务逻辑注册的模块的初始化还没被调用。只有在实际的 ContainerBuilder 调用 Build 方法,才会执行模块的 Load 方法
因此下一个调用就是业务逻辑注册的模块
FooModule 的 Load 方法
按照 Autofac 的定义,在 ConfigureContainer 的 Build 方法里面,才会执行模块的初始化,调用 Load 方法
class FooModule : Autofac.Module { protected override void Load(ContainerBuilder builder) { Console.WriteLine($"06 FooModule"); builder.RegisterType<Foo>().As<IFoo>(); } }
在 Autofac 里面,将会按照模块注册的顺序,调用模块的 Load 方法,如上面代码,可以看到 TestModule 在最后被注册,因此将会最后执行
TestModule 的 Load 方法
在上面代码 TestModule 是最后注册到 Autofac 的模块,也就是 TestModule 将会最后被执行
class TestModule : Autofac.Module { protected override void Load(ContainerBuilder builder) { Console.WriteLine($"07 TestModule"); builder.RegisterType<TestFoo>().As<IFoo>(); } }
如上面代码,在 TestModule 注入的测试类型,将会替换业务代码的实际类型
Autofac 接入的方法
通过上面的方法调用顺序,大家也可以了解为什么集成测试的代码这样写就有效果。更深入的逻辑是 Autofac 的设计,为什么可以让 Autofac 框架可以接入到 ASP.NET Core 应用里面,我在此前可一直都是在 WPF 框架使用的。这个问题其实很简单,所有的 dotnet 项目,无论是 ASP.NET Core 还是 WPF 等,都是相同的 dotnet 逻辑,装配方式都相同,只是顶层业务逻辑实现方法有所不同,因此只需要加一点适配逻辑就能通用
从上面项目安装的 NuGet 包可以看到,安装了 Autofac.Extensions.DependencyInjection
库就是提供 Autofac 与 dotnet 通用依赖注入框架链接的功能,而 ASP.NET Core 原生的框架就是基于 dotnet 通用依赖注入框架,因此就能将 Autofac 接入到 ASP.NET Core 应用
在 UseServiceProviderFactory 方法里面,将会执行 ASP.NET Core 框架的依赖注入容器相关方法,此方法注入的 IServiceProviderFactory 带泛形的类型,将可以支持在 Startup 方法里面添加 ConfigureContainer 方法,参数就是 IServiceProviderFactory 的泛形
如加入了 FakeAutofacServiceProviderFactory 类型,此类型继承了 IServiceProviderFactory<ContainerBuilder>
接口,也就是 IServiceProviderFactory 的 泛形 是 ContainerBuilder 类型,因此可以在 Startup 的 ConfigureContainer 方法参数就是 ContainerBuilder 类型
public class Startup { // 忽略代码 public void ConfigureContainer(ContainerBuilder builder) { } }
而 ConfigureContainer 将会被 Microsoft.AspNetCore.Hosting.GenericWebHostBuilder 进行调用,在 GenericWebHostBuilder 的调用顺序是先调用 ConfigureServices 再调用 对应的 ConfigureContainer 方法
在 Microsoft.Extensions.Hosting.HostBuilder.CreateServiceProvider 方法就是实际创建容器的方法,这个方法里面,将会先调用完成 ConfigureServices 的配置,然后再调用 ConfigureContainer 的配置,代码如下
public class HostBuilder : IHostBuilder { private void CreateServiceProvider() { // 忽略代码 var services = new ServiceCollection(); foreach (Action<HostBuilderContext, IServiceCollection> configureServicesAction in _configureServicesActions) { configureServicesAction(_hostBuilderContext, services); } object containerBuilder = _serviceProviderFactory.CreateBuilder(services); foreach (IConfigureContainerAdapter containerAction in _configureContainerActions) { containerAction.ConfigureContainer(_hostBuilderContext, containerBuilder); } _appServices = _serviceProviderFactory.CreateServiceProvider(containerBuilder); } }
此时的 _serviceProviderFactory
将会是注入的 FakeAutofacServiceProviderFactory 类型,将会调用对应的 CreateBuilder 方法,也就是如下代码将会调用
class FakeAutofacServiceProviderFactory : IServiceProviderFactory<ContainerBuilder> { // 忽略代码 public ContainerBuilder CreateBuilder(IServiceCollection services) { return AutofacServiceProviderFactory.CreateBuilder(services); } }
在 HostBuilder 的 _configureContainerActions
委托调用 ConfigureContainer 的逻辑,实际就是 Startup 类型里面定义的 ConfigureContainer 方法
因此就是先调用 Startup 类型和 IHostBuilder 的 ConfigureServices 方法,然后再调用 ConfigureContainer 方法
在 Autofac 的 AutofacServiceProviderFactory 在 CreateBuilder 方法就可以拿到了原生注册的所有类型,因为在调用 CreateBuilder 之前已经完成了所有的原生逻辑
在 AutofacServiceProviderFactory 的 CreateBuilder 方法将会先创建 ContainerBuilder 对象,然后调用 Populate 方法,从原生的 IServiceCollection 获取注册的类型,重新放到 ContainerBuilder 容器
public ContainerBuilder CreateBuilder(IServiceCollection services) { var builder = new ContainerBuilder(); builder.Populate(services); _configurationAction(builder); return builder; }
上面代码的 ContainerBuilder 是 Autofac 框架的,而 Populate 是扩展方法,和 AutofacServiceProviderFactory 都是在 Autofac.Extensions.DependencyInjection
库提供的,通过此扩展方法和 AutofacServiceProviderFactory 即可实现 Autofac 和 dotnet 原生接入。在 Populate 方法从 dotnet 原生拿到注册的类型,放入到 Autofac 的 ContainerBuilder 里,这样所有之前使用 dotnet 原生注入的类型就可以在 Autofac 拿到
public static void Populate( this ContainerBuilder builder, IEnumerable<ServiceDescriptor> descriptors, object lifetimeScopeTagForSingletons = null) { if (descriptors == null) { throw new ArgumentNullException(nameof(descriptors)); } builder.RegisterType<AutofacServiceProvider>().As<IServiceProvider>().ExternallyOwned(); builder.RegisterType<AutofacServiceScopeFactory>().As<IServiceScopeFactory>(); Register(builder, descriptors, lifetimeScopeTagForSingletons); }
以上的 IEnumerable<ServiceDescriptor>
就是 IServiceCollection 类型的对象,实际代码是 Register 里面拿到注册的类型,重新放入到 Autofac 里
private static void Register( ContainerBuilder builder, IEnumerable<ServiceDescriptor> descriptors, object lifetimeScopeTagForSingletons) { foreach (var descriptor in descriptors) { if (descriptor.ImplementationType != null) { // Test if the an open generic type is being registered var serviceTypeInfo = descriptor.ServiceType.GetTypeInfo(); if (serviceTypeInfo.IsGenericTypeDefinition) { builder .RegisterGeneric(descriptor.ImplementationType) .As(descriptor.ServiceType) .ConfigureLifecycle(descriptor.Lifetime, lifetimeScopeTagForSingletons); } else { builder .RegisterType(descriptor.ImplementationType) .As(descriptor.ServiceType) .ConfigureLifecycle(descriptor.Lifetime, lifetimeScopeTagForSingletons); } } else if (descriptor.ImplementationFactory != null) { var registration = RegistrationBuilder.ForDelegate(descriptor.ServiceType, (context, parameters) => { var serviceProvider = context.Resolve<IServiceProvider>(); return descriptor.ImplementationFactory(serviceProvider); }) .ConfigureLifecycle(descriptor.Lifetime, lifetimeScopeTagForSingletons) .CreateRegistration(); builder.RegisterComponent(registration); } else { builder .RegisterInstance(descriptor.ImplementationInstance) .As(descriptor.ServiceType) .ConfigureLifecycle(descriptor.Lifetime, null); } } }
上面代码拿到的 ServiceDescriptor 就是在原生框架里面的注入类型的定义,可以看到这些都重新放到 Autofac 的容器里面
这就是为什么 Autofac 能拿到在 ASP.NET Core 框架里面其他框架注入的类型的代码
在 HostBuilder 的 CreateServiceProvider 方法最后就是调用 IServiceProviderFactory 的 CreateServiceProvider 方法返回实际的容器
也就是调用了 Autofac 的 CreateServiceProvider 方法,代码如下
public IServiceProvider CreateServiceProvider(ContainerBuilder containerBuilder) { if (containerBuilder == null) throw new ArgumentNullException(nameof(containerBuilder)); var container = containerBuilder.Build(_containerBuildOptions); return new AutofacServiceProvider(container); }
可以看到本质就是调用了 ContainerBuilder 的 Build 方法,而在 Build 方法里面,才会初始化 Autofac 的模块。因此在 FakeAutofacServiceProviderFactory 的 CreateServiceProvider 方法里面添加的代码,是不会在具体业务模块的初始化模块调用之前被调用。但在 Autofac 里面,模块的初始化顺序是模块加入 Autofac 的顺序,因此可以在 FakeAutofacServiceProviderFactory 里面再加入测试的模块,测试的模块将会是最后加入的模块,也就是将会最后被执行
因此想要在接入 Autofac 框架覆盖业务逻辑注册的类型,就需要在 Autofac 里面注册一个测试使用的模块,要求这个模块最后注册,然后在此模块里面进行注册类型,这样就可以让测试模块注册的类型是最后注册的,覆盖原有的类型。而想要让测试模块最后注册,就需要自己实现一个继承 IServiceProviderFactory<ContainerBuilder>
的类型,才能在 AutofacServiceProviderFactory 的 CreateServiceProvider 方法调用之前注册模块
虽然我很喜欢使用 Autofac 框架,但是我觉得在接入 ASP.NET Core 时,没有很好加入测试的机制,而让开发者需要自己理解底层的逻辑才能进行注册测试的类型
这里也需要给 dotnet 的设计点赞,在一开始的 ASP.NET Core 选择依赖注入框架时,选择的是 dotnet 通用依赖注入框架,而 dotnet 通用依赖注入框架最底层的是使用最初的装配器接口,在 C# 语言里面接口的定义是最通用的,接口只约束而不定义。通过这一套传承的定义,可以让 10 多年前的 Autofac 框架依然可以跑在现代的应用里面
这 10 多年也不是 Autofac 啥都不做,上面的说法只是为了说明 dotnet 的兼容性特别强和最初的 dotnet 设计大佬的强大
本文的实现方法,虽然代码很少,但要理解 dotnet 的依赖注入和 ASP.NET Core 的依赖注入使用,和 Autofac 的接入方法。看起来就是 Autofac 的接入机制其实没有考虑全,当然,也许是我的技术不够,也许有更好的实现方法,还请大佬们教教我
代码
本文所有代码放在 github 和 gitee 欢迎小伙伴访问
原文转载:http://www.shaoqun.com/a/767944.html
mein:https://www.ikjzd.com/w/1601
环球华网:https://www.ikjzd.com/w/1063
在使用Autofac框架进行开发后,编写集成测试时,需要用Mock的用于测试的模拟的类型去代替容器里面已注入的实际类型,也就需要在Autofac完全收集完成之后,再次注入模拟的对象进行覆盖原有业务代码注册的正式对象。但Autofac默认没有提供此机制,我阅读了Autofac的源代码之后,创建了一些辅助代码,实现了此功能。本文将告诉大家如何在集成测试里面,在使用了Autofac的项目里面,在所有收集
亚马逊全球开店制造:https://www.ikjzd.com/w/204.html
epc:https://www.ikjzd.com/w/488
心怡物流:https://www.ikjzd.com/w/1327
如何创建一个出色的亚马逊Listing产品详细信息页:https://www.ikjzd.com/tl/107890
银行假日:https://www.ikjzd.com/w/749
跨境卖家如何通过海外社媒营销获取新流量?:https://www.ikjzd.com/articles/103092
没有评论:
发表评论