源宝导读:微软跨平台技术框架—.NET Core已经日趋成熟,已经具备了支撑大型系统稳定运行的条件。本文将介绍明源云ERP平台从.NET Framework向.NET Core迁移过程中的实践经验。
一、背景
随着ERP的产品线越来越多,业务关联也日益复杂,应用间依赖关系也变得错综复杂,单体架构的弱点日趋明显。19年初,由于平台底层支持了分应用部署模式,将ERP从应用子系统层面进行了切割分离,迈出了从单体架构向微服务架构转型的坚实一步。不久的将来,ERP会进一步将各业务拆分成众多的微服务,而微服务势必需要进行容器化部署和运行管理,这就要求ERP技术底层必须支持跨平台,所以将现有ERP系统从.NET Framework迁移到 .NET Core平台势在必行。
本篇将重点讲述Webapi这块,从Clowfish迁移到Asp.net Core框架的一些实践。
二、现状介绍
在改造之前Erp用的是Clownfish框架,与Asp.Net 在路由模块和参数绑定这里有不一样的方式;在路由模块上功能是一致的,都是将Url请求映射到类的方法上进行出处理,只是映射的方式不一样,而参数处理上Erp全部采用Post请求的json请求,支持多参数的绑定,这点在特性上就和MVC有区别,这里先介绍下ClownFish的这两个点。
2.1、Url路由
我们看一个Clownfish的一个url:ajax / Mysoft.Map6.Modeling. Controls.AppChart.ChartAjaxHandler/LoadChartData,这个Url分成三段,ajax这个是请求分类,Mysoft.Map6.Modeling.Controls.AppChart.ChartAjaxHandler这个是类的类型名,LoadChartData 这个是方法,类型名和方法名就可以定位到要处理这个Http请求的处理程序,从而解决路由的问题。
路由的配置是通过web.config中添加如下节点通过IHttpFactory,同时支持AjaxHandler,AppService和PubService,配置如下。
<handlers> <add name="Service-AjaxHandlerFactory" path="/api/*/*" verb="*" type="ClownFish.Web.ServiceHandlerFactory, ClownFish.Web" preCondition="integratedMode" /> <add name="Ajax-AjaxHandlerFactory" path="/ajax/*/*" verb="*" type="ClownFish.Web.ServiceHandlerFactory, ClownFish.Web" preCondition="integratedMode" /> <add name="PublicService-AjaxHandlerFactory" path="/pub/*/*" verb="*" type="ClownFish.Web.ServiceHandlerFactory, ClownFish.Web" preCondition="integratedMode" />handlers>
2.2、参数绑定
参数绑定上Clowfish在Http请求发起的时候将多个参数放到一个Json中,当路由到指定方法之后,获取到参数的方法类型;将http请求接受到的json通过JsonTextReader接受,通过JsonTextReader.Value来匹配参数名,然后通过参数类型反序列化,最后放入到字典中,总之就是将Post请求中的json数据通过字典进行接受,通过反射执行的时候通过参数赋值。
三、Asp.Net Core Mvc简介
为了解决上述两个问题,我们要在Asp.net Core中要进行适配的。首先我们需要了解下MVC的路由和参数绑定的机制。
3.1、路由
在Mvc中配置路由有两种方式,一种是通过全局路由约定进行配置,这种配置的优先级较低,如果控制器或者方法上标记了特性路由那么优先走特性路由。
3.2、全局路由约定
当建立一MVC项目的时候路由就是默认配置好的,如下代码所示:
app.UseMvc(builder =>{ builder.MapRoute("default", "{controller=Home}/{action=Index}/{Id?}"); //添加mvc中间件并使用自定义路由配置 builder.MapRoute("areas", "{area:exists}/{controller=Home}/{action=Index}/{Id?}"); //区域路由配置});
如果url地址栏controller为空那么默认为home,action为空那么默认为index方法,最后一个/id是可空的(带?代表可空的,正则约束)。
举例说明:上述路由配置以后可将Url:User/Get/1 映射到UserController的Get方法上如果get方法上有Id那么id的值为1。
3.3、特性路由
在Controller类上通过RouteAttribute约定进行配置路由,如下例所示:
[Area("organriztion")]//配置区域[Route("user")]//特性配置路由,留空为默认访问此Controllerpublic class UserController{ [Route("")]//特性配置路由,留空为默认访问此action public string Get(int id) { return "mike";} [Route("insert")] public void Insert { }}//[Route("[controller]/[action]")]//特性配置路由public class UserController{ public string Get(int id) { return "mike";} public void Insert() { }}
如果Route标记在控制器上,那么需要传入参数则为匹配的控制器名,如果Route标记在方法上,那么需要传入参数则为匹配方法名。
3.4、参数绑定
Asp.net Core 的参数绑定逻辑是自动实现的,MVC框架的绑定设计到两个问题,一个是从什么地方取,一个是转换成对象, 我们已下面代码为例进行说明:
public class UserController { public string Get(int id) { return "mike";} public void Insert([FromBody]User user) { }}
注意FromBody,这个是告诉Mvc系统会将body里面内容进行读取后绑定到User上,类似的还有FromQuery(从Url获取),FormHeader(从Header中获取),FormServices(从Mvc的Ioc容器中获取)等等。
当从Header和Url中获取的时候如果是简单对象例如string,int类型等等就会之间按照参数的名称获取到对应值进行转换,如果是复杂类型则会根据类的属性进行逐一的简单类型绑定。
当从Body中进行读取时候默认是直接通过序列化的方式,而序列化的方式是通过Header中的ContentType进行指定的,例如application/json就会通过json序列化的方式进行反序列化。
四、Asp.Net Core Mvc改造
有了问题,然后知道基本的使用后我们就要就要分析该如何结合了。
4.1、路由改造切入点分析
有个前提是我们的改造对原来的代码没有任何修改,基于上述前提,原来是通过类型来进行匹配的,这个通过配置全局路由很难做到,而且一般全局路由都是用于Mvc体系中,一般来说特性路由应用于Webapi会更加合适,所以我们考虑从特习惯路由来切入,而特性路由为了不改变代码,那么我们就要从MVC中找出对应的扩展点自动将特性路由添加到MVC路由中。
在MVC中提供了应用程序模型,执行相关的控制器,方法,参数,路由筛选器等进行了统一的加载管理,其实就是最开始吧所有相关的执行信息通过反射的方式进行预先加载到内存并缓存。
ApplicationModel
— ControllerModel
—— ActionModel
——— ParameterModel
这里由于篇幅有限,我们这里就不作全篇的介绍,只重点说明我们要用到的部分。其实只需要下载MVC源码从ApplicationModel的属性开始分析即可,上述几个类中只有属性,而且命名和注释都相当清晰,一看便知。
4.2、ApplicationModel
ApplicationModel对应AssemblyPart通过添加进行加载Assembly,其中包含当前程序集所有的Controller的信息,加载代码如下:
var partManager = serviceProvider.GetService(); var partFactory = ApplicationPartFactory.GetApplicationPartFactory(moduleAssembly);foreach (var part in partFactory.GetApplicationParts(moduleAssembly)){ partManager.ApplicationParts.Add(part);}
我们还可以通过IApplicationModelConvention 在加载完成后进行一些定制。
4.3、ControllerModel
ControllerModel中存放了Controller相关的信息,诸如Controller上的特性路由,筛选器等等,ControllerModel可以通过ControllerFeatureProvider筛选一个程序集中那些类型可以加载为Controller,可以通过IControllerModelConvention 进行定制Controller中的相关信息,特别是路由器和筛选器信息,关于筛选器,我们在之前的篇幅中介绍权限的时候,关于pub接口的权限也是在这里进行扩展的。
4.4、路由改造设计
由上述分析可知,我们需要做如下步骤即可完成对路由的扩展:
扫描目录中的程序集添加到ApplicationPart集合之中;
扩展ControllerFeatureProvider 将程序集中的AjaxHandler,AppService,PublicService判定为Controller;
扩展IControllerModelConvention 扫描Contoller中的公共方法,在方法上根据 {类型(ajax/api/pub)}/{typename}/{action}.aspx的规则添加对应的路由即可。
根据不同的扫描和职责如是有了下述类图设计:
最核心的ClownFishControllerModelConvention代码如下,由于这个是最核心的地方,所以贴上了完整代码,稍微有点多。
internal abstract class ClownFishControllerModelConvention : IControllerModelConvention{ public virtual void Apply(ControllerModel controller) { if (!NeedHandler(controller)) return; ConfigureSelector(controller); ConfigureParamBinder(controller); ConfigureFilter(controller); } protected virtual void ConfigureSelector(ControllerModel controller) { RemoveEmptySelectors(controller.Selectors); if (controller.Selectors.Any(selector => selector.AttributeRouteModel != null)) { return; } foreach (var action in controller.Actions) { ConfigureSelector(action); //action.ApiExplorer.IsVisible =SwaggerVisible; } } protected abstract bool NeedHandler(ControllerModel mode); protected abstract string FixedStartPath { get; } protected abstract string FixedEndPath { get; } protected virtual bool SwaggerVisible => false; #region RouteingRewrite protected virtual void ConfigureSelector(ActionModel action) { RemoveEmptySelectors(action.Selectors); //这里移除所有的对没有标记http方法的action路由进行整理 //如果没有标记走默认规则 //如果标记了按照标记来 if (!action.Selectors.Any()) { AddServiceSelector(action); } else { NormalizeSelectorRoutes(action); } } protected virtual void AddServiceSelector(ActionModel action) { var httpMethod = SelectHttpMethod(action); var attributeRouteModel = CreateServiceAttributeRouteModel( action.Controller.ControllerType, action, FixedStartPath, FixedEndPath); AddDefaultSelectModel(action, attributeRouteModel); } protected void AddDefaultSelectModel(ActionModel action, AttributeRouteModel attributeRouteModel) { action.Selectors.Add( new SelectorModel { AttributeRouteModel = attributeRouteModel, ActionConstraints = { new HttpMethodActionConstraint( new[] { "GET" }) } }); action.Selectors.Add( new SelectorModel { AttributeRouteModel = attributeRouteModel, ActionConstraints = { new HttpMethodActionConstraint( new[] { "POST" }) } }); } protected virtual string SelectHttpMethod(ActionModel action) { return HttpMethodHelper.GetConventionalVerbForMethodName(action.ActionName); } protected virtual void NormalizeSelectorRoutes(ActionModel action) { foreach (var selector in action.Selectors) { /*var httpMethod = selector.ActionConstraints.OfType().FirstOrDefault() ?.HttpMethods?.FirstOrDefault();*/ if (selector.AttributeRouteModel == null) { selector.AttributeRouteModel = CreateServiceAttributeRouteModel(action.Controller.ControllerType, action, FixedStartPath, FixedEndPath); } } } protected virtual AttributeRouteModel CreateServiceAttributeRouteModel( Type controlType, ActionModel action, string rootPath, string endPath) { return new AttributeRouteModel( new RouteAttribute( CalculateRouteTemplate(controlType, action, rootPath, endPath) ) ); } protected virtual string CalculateRouteTemplate( Type controlType, ActionModel action, string rootPath, string endPath) { var controllerNameInUrl = controlType.FullName; var url = $"{rootPath}/{controllerNameInUrl}/{action.ActionName}{endPath}"; return url; } protected virtual void RemoveEmptySelectors(IList selectors) { var emptySelectors = selectors .Where(IsEmptySelector) .ToList(); emptySelectors.ForEach(s => selectors.Remove(s)); } protected virtual bool IsEmptySelector(SelectorModel selector) { return selector.AttributeRouteModel == null && selector.ActionConstraints.IsNullOrEmpty(); } #endregion #region Add Customer Filter protected virtual void ConfigureFilter(ControllerModel controller) { foreach (var action in controller.Actions) { action.Filters.Add(new DefaultAsJsonResultFilterAttribute()); } } #endregion #region ConfigCustomerBinding private void ConfigureParamBinder(ControllerModel controller) { foreach (var action in controller.Actions) { action.Properties[AppConst.ClownFishWebControllerIdentity] = AppConst.ClownFishWebControllerIdentity; } } #endregion}
4.5、参数绑定改造
将http请求数据解析成为方法执行参数实在在Mvc执行的过程中必要的一部,所以这里顺着执行流程找到如下扩展点:
IActionInvoker 方法执行器,用来在路由匹配和参数绑定之后执行;
IActionInvokerProvider 可以在用于创建IActionInvoker,并且可以在执行之前对执行上文修改;
ControllerActionInvokerCacheEntry 缓存的内容;
ControllerActionInvokerCache 根据ActionDescription缓存方法执行的内容;
ControllerBinderDelegate 参数绑定的委托,这里画重点,这里及是我们需要替换的点。
经过上述分析之后我们可以做如下扩展来实现多参数绑定的功能:
ClownFishWebActionInvokerProvider继承自IActionInvokerProvider 在mvc的方法执行之前替换掉缓存中的ControllerActionInvokerCacheEntry;
CustomControllerActionInvokerCache 替换掉ControllerActionInvokerCache,因为MVC自带的ControllerActionInvokerCache是内部类无法重写,所以用一个新的类来代替其原来的功能,不需要扩展的部分直接使用mvc的代码;
ClownFishControllerBinderDelegateProvider 用来生成ControllerBinderDelegate;
JsonDataProvider 最终实现参数绑定逻辑的地方。
由于代码过多这里就不贴出代码了。
五、总结
通过上述两个改造基本就完成了原来Erp的WebApi功能到.Net Core的迁移。
这里我简单说一下我对mvc的理解,不仅仅限于Asp.net Core Mvc的角度:
Web服务器收到Http请求以后将Http请求中的内容赋值到HttpContext对象中;
web服务器根据请求的内容选择不同的组件处理,老版本mvc是HttpModule,core中是Middleware,Java中是Servlet;
组件根据路由规则找到对应Controller类和Action方法;
对Action的参数进行绑定,.Net中会将参数存储到ValueFactory中,然后进行参数的绑定,找到一个匹配就返回;
将Controller激活,一般是通过Ioc容器获取;
执行Action,这里一般会执行相关的Filter,Spring中直接通过Aop的方式实现Filter类似功能;
执行完成以后需要对Action结果进行解析,如果是视图则定位到视图的路径,然后赋值渲染,如果是api请求则直接反序列化;
写入HttpResponse返回。
上面是执行流程,那么伴随执行流程会有个初始化的逻辑,一般是反射程序集或者jar包将类,方法,特性等信息存储起来形成相应的配置信息,以便在调用的时候能更快的通过配置信息去执行请求的逻辑。
上述逻辑只是一个大体的逻辑,可以对大部分mvc框架有一个基本的了解,但是每一步在每一个框架深入都会有不同的实现方式,如果研究Mvc的源代码的话,可以从这几大块着手开始找到自己想切入的点,来定制自己的需求,如果有兴趣的同学想自己实现也可以参考。
------ END ------
作者简介
熊同学: 研发工程师,目前负责ERP运行平台的设计与开发工作。
也许您还想看
【复杂系统迁移 .NET Core平台系列】之认证和授权
【复杂系统迁移 .NET Core平台系列】之迁移项目工程
【复杂系统迁移 .NET Core平台系列】之界面层
【复杂系统迁移 .NET Core平台系列】之静态文件
如何解决大批量数据保存的性能问题