.NET Core开发日志——依赖注入
依赖注入(DI)不是一个新的话题,它的出现是伴随着系统解耦的需要而几乎必然产生的。
在SOLID设计原则中,DIP(Dependency inversion principle)——依赖倒置,规定了“需依赖抽象,而非实现”的准则,该原则主要目的是通过引入抽象(比如接口)的方式降低模块之间的耦合性。与此原则相拟而又有所不同的是IoC(inversion of control)——控制反转设计原则。这项原则定义了应该由通用框架而非外部代码决定控制流(control flow)的概念。对控制反转的实现有数种技术,DI(Dependency injection)——依赖注入便是其中之一,而依赖注入技术同时又支持依赖倒置的设计原则,所以它被广泛使用并不是件令人意外的事情。
依赖注入的基本特性是借由一个对象提供对另一对象的依赖。这样的一个对象通常又被称为容器。容器负责被依赖对象的注册(register),解析(resolve)与释放(release),并具有将被依赖对象注入到依赖对象内部的功能。
在之前的ASP.NET开发过程中,要想使用到依赖注入技术必需依赖第三方类库,而在ASP.NET Core中,这项技术已经被引入到其自身的框架中。
容器
ASP.NET Core中使用ServiceProvider作为依赖注入的容器,它是在WebHostBuilder类中被引入的。
public IWebHost Build() { ... IServiceProvider GetProviderFromFactory(IServiceCollection collection) { var provider = collection.BuildServiceProvider(); var factory = provider.GetService<IServiceProviderFactory<IServiceCollection>>(); if (factory != null) { using (provider) { return factory.CreateServiceProvider(factory.CreateBuilder(collection)); } } return provider; } }
注册
所需依赖的对象通过ServiceCollectionServiceExtensions中的各种扩展方法被加入到ServiceCollection类中。ServiceCollection类内部维护着一个ServiceDescriptor集合。而ServiceCollection又会被传入ServiceProvider的构造方法。
public static IServiceCollection AddTransient( this IServiceCollection services, Type serviceType, Type implementationType) { ... return Add(services, serviceType, implementationType, ServiceLifetime.Transient); } public static IServiceCollection AddScoped( this IServiceCollection services, Type serviceType, Type implementationType) { ... return Add(services, serviceType, implementationType, ServiceLifetime.Scoped); } public static IServiceCollection AddSingleton( this IServiceCollection services, Type serviceType, Type implementationType) { ... return Add(services, serviceType, implementationType, ServiceLifetime.Singleton); } private static IServiceCollection Add( IServiceCollection collection, Type serviceType, Type implementationType, ServiceLifetime lifetime) { var descriptor = new ServiceDescriptor(serviceType, implementationType, lifetime); collection.Add(descriptor); return collection; }
解析
要想获得已注册的对象,可以通过ServiceProviderServiceExtensions类的扩展方法GetService。
public static T GetService<T>(this IServiceProvider provider) { if (provider == null) { throw new ArgumentNullException(nameof(provider)); } return (T)provider.GetService(typeof(T)); }
ServiceProvider的GetService方法其实是调用了它内部各种引擎的父类ServiceProviderEngine的方法。这些引擎间区别在于实现方式以及性能上,功能方面都是一样的。默认引擎是DynamicServiceProviderEngine。
private readonly IServiceProviderEngine _engine; internal ServiceProvider(IEnumerable<ServiceDescriptor> serviceDescriptors, ServiceProviderOptions options) { IServiceProviderEngineCallback callback = null; if (options.ValidateScopes) { callback = this; _callSiteValidator = new CallSiteValidator(); } switch (options.Mode) { case ServiceProviderMode.Dynamic: _engine = new DynamicServiceProviderEngine(serviceDescriptors, callback); break; case ServiceProviderMode.Runtime: _engine = new RuntimeServiceProviderEngine(serviceDescriptors, callback); break; #if IL_EMIT case ServiceProviderMode.ILEmit: _engine = new ILEmitServiceProviderEngine(serviceDescriptors, callback); break; #endif case ServiceProviderMode.Expressions: _engine = new ExpressionsServiceProviderEngine(serviceDescriptors, callback); break; default: throw new NotSupportedException(nameof(options.Mode)); } } public object GetService(Type serviceType) => _engine.GetService(serviceType);
ServiceProviderEngine类的CreateServiceAccessor方法创建了CallSite。
internal object GetService(Type serviceType, ServiceProviderEngineScope serviceProviderEngineScope) { if (_disposed) { ThrowHelper.ThrowObjectDisposedException(); } var realizedService = RealizedServices.GetOrAdd(serviceType, _createServiceAccessor); _callback?.OnResolve(serviceType, serviceProviderEngineScope); return realizedService.Invoke(serviceProviderEngineScope); } private Func<ServiceProviderEngineScope, object> CreateServiceAccessor(Type serviceType) { var callSite = CallSiteFactory.CreateCallSite(serviceType, new CallSiteChain()); if (callSite != null) { _callback?.OnCreate(callSite); return RealizeService(callSite); } return _ => null; }
假设注册是用的public static IServiceCollection AddSingleton(this IServiceCollection services, Type serviceType, Type implementationType)
方法,那么之后的处理会生成一个SingletonCallSite对象并且包含ConstructorCallSite参数值。
private IServiceCallSite TryCreateExact(ServiceDescriptor descriptor, Type serviceType, CallSiteChain callSiteChain) { if (serviceType == descriptor.ServiceType) { IServiceCallSite callSite; if (descriptor.ImplementationInstance != null) { callSite = new ConstantCallSite(descriptor.ServiceType, descriptor.ImplementationInstance); } else if (descriptor.ImplementationFactory != null) { callSite = new FactoryCallSite(descriptor.ServiceType, descriptor.ImplementationFactory); } else if (descriptor.ImplementationType != null) { callSite = CreateConstructorCallSite(descriptor.ServiceType, descriptor.ImplementationType, callSiteChain); } else { throw new InvalidOperationException("Invalid service descriptor"); } return ApplyLifetime(callSite, descriptor, descriptor.Lifetime); } return null; } private IServiceCallSite CreateConstructorCallSite(Type serviceType, Type implementationType, CallSiteChain callSiteChain) { callSiteChain.Add(serviceType, implementationType); var constructors = implementationType.GetTypeInfo() .DeclaredConstructors .Where(constructor => constructor.IsPublic) .ToArray(); IServiceCallSite[] parameterCallSites = null; if (constructors.Length == 0) { throw new InvalidOperationException(Resources.FormatNoConstructorMatch(implementationType)); } else if (constructors.Length == 1) { var constructor = constructors[0]; var parameters = constructor.GetParameters(); if (parameters.Length == 0) { return new CreateInstanceCallSite(serviceType, implementationType); } parameterCallSites = CreateArgumentCallSites( serviceType, implementationType, callSiteChain, parameters, throwIfCallSiteNotFound: true); return new ConstructorCallSite(serviceType, constructor, parameterCallSites); } ... } private IServiceCallSite ApplyLifetime(IServiceCallSite serviceCallSite, object cacheKey, ServiceLifetime descriptorLifetime) { if (serviceCallSite is ConstantCallSite) { return serviceCallSite; } switch (descriptorLifetime) { case ServiceLifetime.Transient: return new TransientCallSite(serviceCallSite); case ServiceLifetime.Scoped: return new ScopedCallSite(serviceCallSite, cacheKey); case ServiceLifetime.Singleton: return new SingletonCallSite(serviceCallSite, cacheKey); default: throw new ArgumentOutOfRangeException(nameof(descriptorLifetime)); } }
ServiceProvider真正解析的是这个生成出来的CallSite对象。
protected override Func<ServiceProviderEngineScope, object> RealizeService(IServiceCallSite callSite) { var callCount = 0; return scope => { if (Interlocked.Increment(ref callCount) == 2) { Task.Run(() => base.RealizeService(callSite)); } return RuntimeResolver.Resolve(callSite, scope); }; } public object Resolve(IServiceCallSite callSite, ServiceProviderEngineScope scope) { return VisitCallSite(callSite, scope); } protected virtual TResult VisitCallSite(IServiceCallSite callSite, TArgument argument) { switch (callSite.Kind) { case CallSiteKind.Factory: return VisitFactory((FactoryCallSite)callSite, argument); case CallSiteKind.IEnumerable: return VisitIEnumerable((IEnumerableCallSite)callSite, argument); case CallSiteKind.Constructor: return VisitConstructor((ConstructorCallSite)callSite, argument); case CallSiteKind.Transient: return VisitTransient((TransientCallSite)callSite, argument); case CallSiteKind.Singleton: return VisitSingleton((SingletonCallSite)callSite, argument); case CallSiteKind.Scope: return VisitScoped((ScopedCallSite)callSite, argument); case CallSiteKind.Constant: return VisitConstant((ConstantCallSite)callSite, argument); case CallSiteKind.CreateInstance: return VisitCreateInstance((CreateInstanceCallSite)callSite, argument); case CallSiteKind.ServiceProvider: return VisitServiceProvider((ServiceProviderCallSite)callSite, argument); case CallSiteKind.ServiceScopeFactory: return VisitServiceScopeFactory((ServiceScopeFactoryCallSite)callSite, argument); default: throw new NotSupportedException($"Call site type {callSite.GetType()} is not supported"); } }
因为上例中CallSite的类型是Constructor,所以最终通过VisitConstructor方法获得所依赖的对象。
protected override object VisitConstructor(ConstructorCallSite constructorCallSite, ServiceProviderEngineScope scope) { object[] parameterValues = new object[constructorCallSite.ParameterCallSites.Length]; for (var index = 0; index < parameterValues.Length; index++) { parameterValues[index] = VisitCallSite(constructorCallSite.ParameterCallSites[index], scope); } try { return constructorCallSite.ConstructorInfo.Invoke(parameterValues); } catch (Exception ex) when (ex.InnerException != null) { ExceptionDispatchInfo.Capture(ex.InnerException).Throw(); // The above line will always throw, but the compiler requires we throw explicitly. throw; } }
至于创建对象的方法是用反射,表达式树(Expression Tree)还是IL Emit,则取决于所使用的内部引擎。
释放
创建ServiceProviderEngine的时候会为其Root属性绑定ServiceProviderEngineScope类型的值,Root = new ServiceProviderEngineScope(this);
。
在ServiceProviderEngineScope类内部有着用于释放资源的Dispose方法。
public void Dispose() { lock (ResolvedServices) { if (_disposed) { return; } _disposed = true; if (_disposables != null) { for (var i = _disposables.Count - 1; i >= 0; i--) { var disposable = _disposables[i]; disposable.Dispose(); } _disposables.Clear(); } ResolvedServices.Clear(); } } internal object CaptureDisposable(object service) { _captureDisposableCallback?.Invoke(service); if (!ReferenceEquals(this, service)) { if (service is IDisposable disposable) { lock (ResolvedServices) { if (_disposables == null) { _disposables = new List<IDisposable>(); } _disposables.Add(disposable); } } } return service; }
并不是所有对象都会通过ServiceProvider容器释放资源,只有容器自己创建的才可以。如果是新建对象再传入容器,容器不会为其作处理。
public void ConfigureServices(IServiceCollection services) { // 容器创建了实例所以会释放它。 services.AddScoped<Service1>(); services.AddSingleton<Service2>(); services.AddSingleton<ISomeService>(sp => new SomeServiceImplementation()); // 容器没有创建实例所以不会释放它。 services.AddSingleton<Service3>(new Service3()); services.AddSingleton(new Service3()); }
注入
ASP.NET Core中最常用的是Constructor Inject(构造器注入)方式。在其MVC框架中,通过DefaultControllerActivator生成Controller时,就可以跟踪到依赖注入是如何被其使用的。
public virtual object Create(ControllerContext controllerContext) { ... var serviceProvider = controllerContext.HttpContext.RequestServices; return _typeActivatorCache.CreateInstance<object>(serviceProvider, controllerTypeInfo.AsType()); }
DefaultControllerActivator的Create方法使用了TypeActivatorCache类,其内部用到了ActivatorUtilities.CreateFactory方法。
public class TypeActivatorCache : ITypeActivatorCache { private readonly Func<Type, ObjectFactory> _createFactory = (type) => ActivatorUtilities.CreateFactory(type, Type.EmptyTypes); private readonly ConcurrentDictionary<Type, ObjectFactory> _typeActivatorCache = new ConcurrentDictionary<Type, ObjectFactory>(); public TInstance CreateInstance<TInstance>( IServiceProvider serviceProvider, Type implementationType) { ... var createFactory = _typeActivatorCache.GetOrAdd(implementationType, _createFactory); return (TInstance)createFactory(serviceProvider, arguments: null); } }
ActivatorUtilities类位于ServiceProvider同样的程序集中。
public static ObjectFactory CreateFactory(Type instanceType, Type[] argumentTypes) { FindApplicableConstructor(instanceType, argumentTypes, out ConstructorInfo constructor, out int?[] parameterMap); var provider = Expression.Parameter(typeof(IServiceProvider), "provider"); var argumentArray = Expression.Parameter(typeof(object[]), "argumentArray"); var factoryExpressionBody = BuildFactoryExpression(constructor, parameterMap, provider, argumentArray); var factoryLamda = Expression.Lambda<Func<IServiceProvider, object[], object>>( factoryExpressionBody, provider, argumentArray); var result = factoryLamda.Compile(); return result.Invoke; }
留意BuildFactoryExpression方法中GetServiceInfo变量。
private static Expression BuildFactoryExpression( ConstructorInfo constructor, int?[] parameterMap, Expression serviceProvider, Expression factoryArgumentArray) { var constructorParameters = constructor.GetParameters(); var constructorArguments = new Expression[constructorParameters.Length]; for (var i = 0; i < constructorParameters.Length; i++) { var constructorParameter = constructorParameters[i]; var parameterType = constructorParameter.ParameterType; var hasDefaultValue = ParameterDefaultValue.TryGetDefaultValue(constructorParameter, out var defaultValue); if (parameterMap[i] != null) { constructorArguments[i] = Expression.ArrayAccess(factoryArgumentArray, Expression.Constant(parameterMap[i])); } else { var parameterTypeExpression = new Expression[] { serviceProvider, Expression.Constant(parameterType, typeof(Type)), Expression.Constant(constructor.DeclaringType, typeof(Type)), Expression.Constant(hasDefaultValue) }; constructorArguments[i] = Expression.Call(GetServiceInfo, parameterTypeExpression); } ... } return Expression.New(constructor, constructorArguments); }
GetServiceInfo变量申明了对GetService方法的调用,而此GetService其实正是对ServiceProvider的调用。
private static readonly MethodInfo GetServiceInfo = GetMethodInfo<Func<IServiceProvider, Type, Type, bool, object>>((sp, t, r, c) => GetService(sp, t, r, c)); private static object GetService(IServiceProvider sp, Type type, Type requiredBy, bool isDefaultParameterRequired) { var service = sp.GetService(type); ... return service; }
通过以上的处理,在创建Controller时,其构造方法中所需参数的类型也会被容器解析,创建相应实例,从而实现依赖注入功能。
生命周期
ASP.NET Core容器可以创建三种生命周期的对象:
- Transient, 每次取得的都是新的对象。
- Scoped, 每次ASP.NET请求生成不同的对象。
- Singleton,同一对象只会生成一次。
检视这三者类型的构造方法,可以很容易理解Scoped与Singleton是通过缓存的方式实现对象的重用。
public TransientCallSite(IServiceCallSite serviceCallSite) { ServiceCallSite = serviceCallSite; } public ScopedCallSite(IServiceCallSite serviceCallSite, object cacheKey) { ServiceCallSite = serviceCallSite; CacheKey = cacheKey; } public SingletonCallSite(IServiceCallSite serviceCallSite, object cacheKey) : base(serviceCallSite, cacheKey) { }
性能
由于ServiceProvider容器使用了反射,表达式树以及IL Emit方式创建对象,可能会对其性能有所担忧,但实际检测的结果,除了Runtime引擎表现不尽如人意外,其它引擎的性能还是在可接受范围内的。
public class GetServiceBenchmark { private const int OperationsPerInvoke = 50000; private IServiceProvider _transientSp; private ServiceProviderMode _mode; [Params("Expressions", "Dynamic", "Runtime", "ILEmit")] public string Mode { set { _mode = (ServiceProviderMode)Enum.Parse(typeof(ServiceProviderMode), value); } } [Benchmark(Baseline = true, OperationsPerInvoke = OperationsPerInvoke)] public void NoDI() { for (int i = 0; i < OperationsPerInvoke; i++) { var temp = new A(new B(new C())); temp.Foo(); } } [GlobalSetup(Target = nameof(Transient))] public void SetupTransient() { var services = new ServiceCollection(); services.AddTransient<A>(); services.AddTransient<B>(); services.AddTransient<C>(); _transientSp = services.BuildServiceProvider(new ServiceProviderOptions() { Mode = _mode }); } [Benchmark(OperationsPerInvoke = OperationsPerInvoke)] public void Transient() { for (int i = 0; i < OperationsPerInvoke; i++) { var temp = _transientSp.GetService<A>(); temp.Foo(); } } private class A { public A(B b) { } [MethodImpl(MethodImplOptions.NoInlining)] public void Foo() { } } private class B { public B(C c) { } } private class C { } }
// ***** BenchmarkRunner: Finish ***** // * Export * // * Detailed results * GetServiceBenchmark.NoDI: Job-NHLENA(Toolchain=InProcessToolchain, RunStrategy=Throughput) [Mode=Dynamic] Runtime = ; GC = Mean = 5.5175 ns, StdErr = 0.0116 ns (0.21%); N = 15, StdDev = 0.0449 ns Min = 5.4490 ns, Q1 = 5.4860 ns, Median = 5.5207 ns, Q3 = 5.5641 ns, Max = 5.5972 ns IQR = 0.0781 ns, LowerFence = 5.3688 ns, UpperFence = 5.6813 ns ConfidenceInterval = [5.4695 ns; 5.5654 ns] (CI 99.9%), Margin = 0.0480 ns (0.87% of Mean) Skewness = 0.15, Kurtosis = 1.67 GetServiceBenchmark.Transient: Job-NHLENA(Toolchain=InProcessToolchain, RunStrategy=Throughput) [Mode=Dynamic] Runtime = ; GC = Mean = 43.1601 ns, StdErr = 0.0677 ns (0.16%); N = 15, StdDev = 0.2620 ns Min = 42.7731 ns, Q1 = 42.9117 ns, Median = 43.2403 ns, Q3 = 43.3580 ns, Max = 43.5392 ns IQR = 0.4464 ns, LowerFence = 42.2421 ns, UpperFence = 44.0276 ns ConfidenceInterval = [42.8800 ns; 43.4402 ns] (CI 99.9%), Margin = 0.2801 ns (0.65% of Mean) Skewness = -0.2, Kurtosis = 1.59 GetServiceBenchmark.NoDI: Job-NHLENA(Toolchain=InProcessToolchain, RunStrategy=Throughput) [Mode=Expressions] Runtime = ; GC = Mean = 5.6964 ns, StdErr = 0.0388 ns (0.68%); N = 33, StdDev = 0.2226 ns Min = 5.5148 ns, Q1 = 5.5603 ns, Median = 5.6042 ns, Q3 = 5.6769 ns, Max = 6.2460 ns IQR = 0.1166 ns, LowerFence = 5.3854 ns, UpperFence = 5.8518 ns ConfidenceInterval = [5.5561 ns; 5.8368 ns] (CI 99.9%), Margin = 0.1404 ns (2.46% of Mean) Skewness = 1.48, Kurtosis = 3.69 GetServiceBenchmark.Transient: Job-NHLENA(Toolchain=InProcessToolchain, RunStrategy=Throughput) [Mode=Expressions] Runtime = ; GC = Mean = 43.6662 ns, StdErr = 0.0995 ns (0.23%); N = 13, StdDev = 0.3586 ns Min = 43.1083 ns, Q1 = 43.5089 ns, Median = 43.6051 ns, Q3 = 43.7178 ns, Max = 44.6669 ns IQR = 0.2089 ns, LowerFence = 43.1956 ns, UpperFence = 44.0311 ns ConfidenceInterval = [43.2368 ns; 44.0957 ns] (CI 99.9%), Margin = 0.4295 ns (0.98% of Mean) Skewness = 1.41, Kurtosis = 5.18 GetServiceBenchmark.NoDI: Job-NHLENA(Toolchain=InProcessToolchain, RunStrategy=Throughput) [Mode=ILEmit] Runtime = ; GC = Mean = 5.6016 ns, StdErr = 0.0071 ns (0.13%); N = 13, StdDev = 0.0255 ns Min = 5.5547 ns, Q1 = 5.5896 ns, Median = 5.5996 ns, Q3 = 5.6226 ns, Max = 5.6400 ns IQR = 0.0330 ns, LowerFence = 5.5401 ns, UpperFence = 5.6721 ns ConfidenceInterval = [5.5712 ns; 5.6321 ns] (CI 99.9%), Margin = 0.0305 ns (0.54% of Mean) Skewness = -0.47, Kurtosis = 2.12 GetServiceBenchmark.Transient: Job-NHLENA(Toolchain=InProcessToolchain, RunStrategy=Throughput) [Mode=ILEmit] Runtime = ; GC = Mean = 43.1397 ns, StdErr = 0.0726 ns (0.17%); N = 15, StdDev = 0.2812 ns Min = 42.7061 ns, Q1 = 42.9064 ns, Median = 43.1052 ns, Q3 = 43.3093 ns, Max = 43.6443 ns IQR = 0.4028 ns, LowerFence = 42.3022 ns, UpperFence = 43.9135 ns ConfidenceInterval = [42.8392 ns; 43.4403 ns] (CI 99.9%), Margin = 0.3006 ns (0.70% of Mean) Skewness = 0.28, Kurtosis = 1.9 GetServiceBenchmark.NoDI: Job-NHLENA(Toolchain=InProcessToolchain, RunStrategy=Throughput) [Mode=Runtime] Runtime = ; GC = Mean = 6.4814 ns, StdErr = 0.0762 ns (1.18%); N = 100, StdDev = 0.7617 ns Min = 5.4979 ns, Q1 = 5.8327 ns, Median = 6.3039 ns, Q3 = 6.9775 ns, Max = 8.0420 ns IQR = 1.1448 ns, LowerFence = 4.1155 ns, UpperFence = 8.6947 ns ConfidenceInterval = [6.2231 ns; 6.7397 ns] (CI 99.9%), Margin = 0.2583 ns (3.99% of Mean) Skewness = 0.52, Kurtosis = 1.94 GetServiceBenchmark.Transient: Job-NHLENA(Toolchain=InProcessToolchain, RunStrategy=Throughput) [Mode=Runtime] Runtime = ; GC = Mean = 581.5066 ns, StdErr = 1.6962 ns (0.29%); N = 15, StdDev = 6.5695 ns Min = 571.4934 ns, Q1 = 576.3829 ns, Median = 580.8121 ns, Q3 = 587.2645 ns, Max = 596.3317 ns IQR = 10.8816 ns, LowerFence = 560.0605 ns, UpperFence = 603.5869 ns ConfidenceInterval = [574.4834 ns; 588.5297 ns] (CI 99.9%), Margin = 7.0232 ns (1.21% of Mean) Skewness = 0.58, Kurtosis = 2.48 Total time: 00:03:11 (191.85 sec)
第三方容器
如果想用第三方容器替换ASP.NET Core原有的容器也是可以办到的。以最常见的Autofac为例,有两种实现方式:
借助ConfigureContainer方法,要先在Program类中挂载AddAutofac方法。
public class Program { public static void Main(string[] args) { CreateWebHostBuilder(args).Build().Run(); } public static IWebHostBuilder CreateWebHostBuilder(string[] args) => WebHost.CreateDefaultBuilder(args).ConfigureServices(services => services.AddAutofac()) .UseStartup<Startup>(); }
然后在Startup类中加入ConfigureContainer方法。
public void ConfigureContainer(ContainerBuilder builder) { builder.RegisterModule(new DefaultModule()); }
例中的DefaultModule类按照Autofac的通用方式实现。
public class DefaultModule : Module { protected override void Load(ContainerBuilder builder) { builder.RegisterType<HelloWorld>().As<IHelloWorld>(); } }
如果不想使用ConfigureContainer方法,也可以直接利用ConfigureServices方法:
public IServiceProvider ConfigureServices(IServiceCollection services) { services.AddMvc(); var containerBuilder = new ContainerBuilder(); containerBuilder.RegisterModule<DefaultModule>(); containerBuilder.Populate(services); var container = containerBuilder.Build(); return new AutofacServiceProvider(container); }
需要注意的是,使用这种方式时,ConfigureServices方法的返回类型要从void改成IServiceProvider。
上一篇: 我成功创业的经验
下一篇: JVM难学?那是因为你没认真看完这篇文章
推荐阅读
-
ASP.NET MVC IOC依赖注入之Autofac系列开篇
-
.NET Core开发的iNeuOS工业互联网平台,发布 iNeuDA 数据分析展示组件,快捷开发图形报表和数据大屏
-
.NET Core开发的iNeuOS工业互联平台,升级四大特性:配置数据接口、图元绑定数据、预警配置和自定义菜单
-
Asp.net Core全局异常监控和记录日志
-
ASP.Net Core on Linux (CentOS7) 共享第三方依赖库部署
-
ASP.NET Core依赖注入系列教程之服务的注册与提供
-
ASP.NET Core DI手动获取注入对象的方法
-
超简单让.NET Core开发者快速拥有CI/CD的能力-Docker版本
-
我来告诉你:VS2019开发ASP.NET Core 3.0 Web项目,修改视图后,刷新浏览器看不到修改后的效果怎么处理
-
详解.NET Core+Docker 开发微服务