Asp .Net Core 系列:Asp .Net Core 集成 Panda.DynamicWebApi

目录

简介

Panda.DynamicWebApi 是一个动态生成WebApi的组件,生成的API符合Restful风格,受启发于ABP。它可以根据符合条件的类来生成WebApi,由MVC框架直接调用逻辑,无性能问题,完美兼容Swagger来构建API说明文档,与手动编写Controller相比并无区别。

应用场景:DDD架构中的应用逻辑层,可使用本组件来直接生成WebApi,而无需再用Controller来调用。

Asp .Net Core 集成 Panda.DynamicWebApi

(1)新建一个 ASP.NET Core WebApi(或MVC) 项目

(2)通过Nuget安装组件

Install-Package Panda.DynamicWebApi

(3)创建一个类命名为 AppleAppService,实现 IDynamicWebApi 接口,并加入特性 [DynamicWebApi]

[DynamicWebApi]
public class AppleAppService: IDynamicWebApi
{
    private static readonly Dictionary<int,string> Apples=new Dictionary<int, string>()
    {
        [1]="Big Apple",
        [2]="Small Apple"
    };

    /// <summary>
    /// Get An Apple.
    /// </summary>
    /// <param name="id"></param>
    /// <returns></returns>
    [HttpGet("{id:int}")]
    public string Get(int id)
    {
        if (Apples.ContainsKey(id))
        {
            return Apples[id];
        }
        else
        {
            return "No Apple!";
        }
    }

    /// <summary>
    /// Get All Apple.
    /// </summary>
    /// <returns></returns>
    public IEnumerable<string> Get()
    {
        return Apples.Values;
    }

    public void Update(UpdateAppleDto dto)
    {
        if (Apples.ContainsKey(dto.Id))
        {
            Apples[dto.Id] =dto.Name;
        }
    }

    /// <summary>
    /// Delete Apple
    /// </summary>
    /// <param name="id">Apple Id</param>
    [HttpDelete("{id:int}")]
    public void Delete(int id)
    {
        if (Apples.ContainsKey(id))
        {
            Apples.Remove(id);
        }
    }

}

(4)在 Startup 中注册 DynamicWebApi

public void ConfigureServices(IServiceCollection services)
{
    // 默认配置
    services.AddDynamicWebApi();

    // 自定义配置
    services.AddDynamicWebApi((options) =>
    {
        // 指定全局默认的 api 前缀
        options.DefaultApiPrefix = "apis";
    
        /**
         * 清空API结尾,不删除API结尾;
         * 若不清空 CreatUserAsync 将变为 CreateUser
         */
        options.RemoveActionPostfixes.Clear();
    
        /**
         * 自定义 ActionName 处理函数;
         */
        options.GetRestFulActionName = (actionName) => actionName;
    
        /**
         * 指定程序集 配置 url 前缀为 apis
         * 如: http://localhost:8080/apis/User/CreateUser
         */
        options.AddAssemblyOptions(this.GetType().Assembly, apiPreFix: "apis");
    
        /**
         * 指定程序集 配置所有的api请求方式都为 POST
         */
        options.AddAssemblyOptions(this.GetType().Assembly, httpVerb: "POST");
    
        /**
         * 指定程序集 配置 url 前缀为 apis, 且所有请求方式都为POST
         * 如: http://localhost:8080/apis/User/CreateUser
         */
        options.AddAssemblyOptions(this.GetType().Assembly, apiPreFix: "apis", httpVerb: "POST");
    });
}

(5)添加 Swagger

C# 复制代码
            builder.Services.AddSwaggerGen(options =>
            {
                options.SwaggerDoc("v1", new OpenApiInfo() { Title = "Panda Dynamic WebApi", Version = "v1" });

                // TODO:一定要返回true!
                options.DocInclusionPredicate((docName, description) => true);

                var baseDirectory = System.AppDomain.CurrentDomain.BaseDirectory;
                var xmlFile = System.AppDomain.CurrentDomain.FriendlyName + ".xml";
                var xmlPath = Path.Combine(baseDirectory, xmlFile);
                options.IncludeXmlComments(xmlPath);
            });

配置

所有的配置均在对象 DynamicWebApiOptions 中,说明如下:

属性名 是否必须 说明
DefaultHttpVerb 默认值:POST。默认HTTP动词
DefaultAreaName 默认值:空。Area 路由名称
DefaultApiPrefix 默认值:api。API路由前缀
RemoveControllerPostfixes 默认值:AppService/ApplicationService。类名需要移除的后缀
RemoveActionPostfixes 默认值:Async。方法名需要移除的后缀
FormBodyBindingIgnoredTypes 默认值:IFormFile。不通过MVC绑定到参数列表的类型。

原理

什么是POCO Controller?

POCO Controller是 ASP.NET Core 中的一个特性,虽然在2015年刚发布的时候就有这个特性了,可是大多数开发者都只是按原有的方式去写,而没有用到这个特性。其实,如果利用这个特性进行稍微封装后,用在SOA架构中Service层的场景中是极其便利的。这篇文章主要就是说我最近在学习使用开源AOP库AspectCore写WebApi动态代理客户端的时候,实现为普通类无添加WebApi服务的过程。

POCO控制器就是ASP.NET Core项目中所有带有Controller后缀的类、或者标记了[Controller]特性的类,虽然没有像模版项目中那样继承自Controller类,也会被识别为控制器,拥有跟普通控制器一样的功能,像下面这段代码中,两个类都会被识别成控制器:

c# 复制代码
public class PocoController
{
    public IActionResult Index()
    {
        return new ContentResult() { Content = "Hello from POCO controller!" };
    }
}
[Controller]
public class Poco
{
    public IActionResult Index()
    {
        return new ContentResult() { Content = "Hello from POCO controller!" };
    }
}

POCO控制器原理

其实,在ASP.NET Core中,已经不像旧版本的 ASP.NET WebApi 那样,通过ControllerFactory来创建Controller,多亏于ASP.NET Core一脉相承的IoC框架 Microsoft.Extensions.DependencyInjection,ASP.NET Core中的内部实现变得更优雅。其中POCO控制器的核心原理就在IApplicationFeatureProvider<ControllerFeature>这个接口的实现ControllerFeatureProvider。

通过aspnet/Mvc项目的Github源码仓库中查询得知,Mvc里把Controller、ViewComponent、TagHelper、Views等组件定义为特性(Feature),如ControllerFeature,特性里就存放了应用中被识别为相组件的类型的集合,如如ControllerFeature中就存放了所有Controller类型。IApplicationFeatureProvider<ControllerFeature>这个接口是用来给MVC框架提供控制器类型识别的接口,当把这个接口的实现注册到服务配置中,就能为其中识别的类型提供控制器功能。

ControllerFeatureProvider是这个接口的默认实现,其中有一个方法IsController(TypeInfo typeInfo)的功能就是判断某类型是否为控制器的。而接口方法PopulateFeature(IEnumerable<ApplicationPart> parts,ControllerFeature feature)则为把传入的 "Mvc应用部分(ApplicationPart,大概是指Mvc的作用程序集)"中的类型都一一判断,如果是控制器,那么就加入控制器特性对象中。

ControllerFeatureProvider

ControllerFeatureProviderASP.NET Core MVC 框架中的一个类,它实现了 IApplicationFeatureProvider<ControllerFeature> 接口。这个类的主要作用是提供控制器类型的识别功能。

ASP.NET Core MVC 中,控制器是用来处理 HTTP 请求的类。传统的控制器类需要继承自 Controller 基类,但 ASP.NET Core 引入了一个新特性,即 POCO(Plain Old CLR Object)控制器。POCO 控制器允许你创建没有继承自 Controller 基类的类,但仍然可以将其识别为控制器,并赋予其处理 HTTP 请求的能力。

ControllerFeatureProvider 就是负责识别这些 POCO 控制器的类。它实现了 IApplicationFeatureProvider<ControllerFeature> 接口的 PopulateFeature 方法,该方法会在 MVC 框架构建应用模型时被调用。在这个方法中,ControllerFeatureProvider 会扫描应用程序中的类型,并根据一定的规则判断哪些类型应该被识别为控制器。

默认情况下,ControllerFeatureProvider 会将所有带有 "Controller" 后缀的类,或者使用了 [Controller] 特性的类识别为控制器。但你也可以通过自定义 ControllerFeatureProvider 的子类来提供自己的识别规则,以满足特定的需求。

https://github.com/dotnet/aspnetcore/blob/main/src/Mvc/Mvc.Core/src/Controllers/ControllerFeatureProvider.cs

实现自定义判断规则

通过上面的剖析,我们就知道要实现自定义的控制器判断规则,只需要重写ControllerFeature类或者重新实现IApplicationFeatureProvider 接口,但是由于PopulateFeature不是虚方法或抽象方法,所以不能被重写,那么只能重新写一个类来实现IApplicationFeatureProvider 接口了:

c# 复制代码
 public class MyDynamicControllerFeatureProvider : ControllerFeatureProvider
 {
     protected override bool IsController(TypeInfo typeInfo)
     {
            var typeInfo = type.GetTypeInfo();
         
            if (!typeof(IDynamicWebApi).IsAssignableFrom(type) ||
                !typeInfo.IsPublic || typeInfo.IsAbstract || typeInfo.IsGenericType)
            {
                return false;
            }


            var attr = ReflectionHelper.GetSingleAttributeOrDefaultByFullSearch<DynamicWebApiAttribute>(typeInfo);

            if (attr == null)
            {
                return false;
            }

            if (ReflectionHelper.GetSingleAttributeOrDefaultByFullSearch<NonDynamicWebApiAttribute>(typeInfo) != null)
            {
                return false;
            }

            return true;
     }
 }

IApplicationModelConvention

IApplicationModelConvention 是ASP.NET Core中的一个接口,它允许开发者在应用模型构建过程中应用自定义约定。ASP.NET Core 的应用模型是描述如何构建 HTTP 请求处理管道的一组组件和服务。

通过实现 IApplicationModelConvention 接口,开发者可以注册中间件、修改路由、添加模型绑定器、配置控制器和服务等。这些约定在应用的启动过程中被应用,通常在 Startup.ConfigureServices 方法中通过调用 AddApplicationPartApplyApplicationPartManager 方法来注册。

https://learn.microsoft.com/zh-cn/dotnet/api/microsoft.aspnetcore.mvc.applicationmodels.iapplicationmodelconvention?view=aspnetcore-8.0

下面是一个简单的 IApplicationModelConvention 实现示例,该示例演示了如何为所有控制器添加一个自定义操作筛选器:

c# 复制代码
using Microsoft.AspNetCore.Mvc;  
using Microsoft.AspNetCore.Mvc.ApplicationModels;  
  
public class CustomConvention : IApplicationModelConvention  
{  
    public void Apply(ApplicationModel application)  
    {  
        foreach (var controller in application.Controllers)  
        {  
            // 为每个控制器添加自定义操作筛选器  
            controller.Filters.Add(new CustomActionFilter());  
        }  
    }  
}  
  
public class CustomActionFilter : IActionFilter  
{  
    public void OnActionExecuting(ActionExecutingContext context)  
    {  
        // 在操作执行前执行的代码  
    }  
  
    public void OnActionExecuted(ActionExecutedContext context)  
    {  
        // 在操作执行后执行的代码  
    }  
}

然后,在 Startup.ConfigureServices 方法中注册这个约定:

csharp 复制代码
public void ConfigureServices(IServiceCollection services)  
{  
    services.AddControllers();  
  
    services.AddApplicationPart(typeof(Startup).Assembly)  
            .ApplyApplicationPartManager(manager =>  
            {  
                manager.Conventions.Add(new CustomConvention());  
            });  
}

在这个例子中,CustomConvention 被添加到了 ApplicationModel 的约定集合中。每当 ASP.NET Core 构建应用模型时,Apply 方法就会被调用,并且所有的控制器都会被添加 CustomActionFilter 筛选器。

Panda.DynamicWebApi中的实现

ConfigureApiExplorer()

首先,是对ApiExplorer进行配置。通过ApiExplorer,我们可以控制Controller级别和Action级别的Web API的可见性。一般情况下的用法是在Controller或者Action上添加ApiExplorerSettings标记,而在这里,我们只需要给ControllerModel和ActionModel的ApiExplorer属性赋值即可。

c# 复制代码
 private void ConfigureApiExplorer(ControllerModel controller)
 {
     if (controller.ApiExplorer.GroupName.IsNullOrEmpty())
     {
         controller.ApiExplorer.GroupName = controller.ControllerName;
     }

     if (controller.ApiExplorer.IsVisible == null)
     {
         controller.ApiExplorer.IsVisible = true;
     }

     foreach (var action in controller.Actions)
     {
         if (!CheckNoMapMethod(action))
             ConfigureApiExplorer(action);
     }
 }

 private void ConfigureApiExplorer(ActionModel action)
 {
     if (action.ApiExplorer.IsVisible == null)
     {
         action.ApiExplorer.IsVisible = true;
     }
 }

ConfigureSelector()

接下来,是对路由进行配置。这部分的核心其实就是根据AreaName、ControllerName、ActionName来生成路由信息,我们会为没有配置过特性路由的Action生成默认的路由,这其实就是MVC里约定大于配置的一种体现啦。在这里会涉及到对ControllerName和ActionName的优化调整,主要体现在两个方面,其一是对类似XXXService、XXXController等这样的后缀进行去除,使其构造出的Api路由更加短小精简;其二是对ActionName里的Get/Save/Update等动词进行替换,使其构造出的Api路由更加符合RESTful风格。

c# 复制代码
     private void ConfigureSelector(ControllerModel controller, DynamicWebApiAttribute controllerAttr)
     {

         if (controller.Selectors.Any(selector => selector.AttributeRouteModel != null))
         {
             return;
         }

         var areaName = string.Empty;

         if (controllerAttr != null)
         {
             areaName = controllerAttr.Module;
         }

         foreach (var action in controller.Actions)
         {
             if (!CheckNoMapMethod(action))
                 ConfigureSelector(areaName, controller.ControllerName, action);
         }
     }

     private void ConfigureSelector(string areaName, string controllerName, ActionModel action)
     {

         var nonAttr = ReflectionHelper.GetSingleAttributeOrDefault<NonDynamicWebApiAttribute>(action.ActionMethod);

         if (nonAttr != null)
         {
             return;
         }

         if (action.Selectors.IsNullOrEmpty() || action.Selectors.Any(a => a.ActionConstraints.IsNullOrEmpty()))
         {
             if (!CheckNoMapMethod(action))
                 AddAppServiceSelector(areaName, controllerName, action);
         }
         else
         {
             NormalizeSelectorRoutes(areaName, controllerName, action);
         }
     }

     private void AddAppServiceSelector(string areaName, string controllerName, ActionModel action)
     {

         var verb = GetHttpVerb(action);

         action.ActionName = GetRestFulActionName(action.ActionName);

         var appServiceSelectorModel = action.Selectors[0];

         if (appServiceSelectorModel.AttributeRouteModel == null)
         {
             appServiceSelectorModel.AttributeRouteModel = CreateActionRouteModel(areaName, controllerName, action);
         }

         if (!appServiceSelectorModel.ActionConstraints.Any())
         {
             appServiceSelectorModel.ActionConstraints.Add(new HttpMethodActionConstraint(new[] { verb }));
             switch (verb)
             {
                 case "GET":
                     appServiceSelectorModel.EndpointMetadata.Add(new HttpGetAttribute());
                     break;
                 case "POST":
                     appServiceSelectorModel.EndpointMetadata.Add(new HttpPostAttribute());
                     break;
                 case "PUT":
                     appServiceSelectorModel.EndpointMetadata.Add(new HttpPutAttribute());
                     break;
                 case "DELETE":
                     appServiceSelectorModel.EndpointMetadata.Add(new HttpDeleteAttribute());
                     break;
                 default:
                     throw new Exception($"Unsupported http verb: {verb}.");
             }
         }


     }

ConfigureParameters()

接下来参数绑定相对简单,因为简单类型MVC自己就能完成绑定,所以,我们只需要关注复杂类型的绑定即可,最常见的一种绑定方式是FromBody:

c# 复制代码
        private void ConfigureParameters(ControllerModel controller)
        {
            foreach (var action in controller.Actions)
            {
                if (!CheckNoMapMethod(action))
                    foreach (var para in action.Parameters)
                    {
                        if (para.BindingInfo != null)
                        {
                            continue;
                        }

                        if (!TypeHelper.IsPrimitiveExtendedIncludingNullable(para.ParameterInfo.ParameterType))
                        {
                            if (CanUseFormBodyBinding(action, para))
                            {
                                para.BindingInfo = BindingInfo.GetBindingInfo(new[] { new FromBodyAttribute() });
                            }
                        }
                    }
            }
        }