1、背景
去年时候,写过一篇《Vue2.0 + Element-UI + WebAPI实践:简易个人记账系统》,采用Asp.net Web API + Element-UI。当时主要是为了练手新学的Vue及基于Vue的PC端前端框架Element-UI,所以文章重点放在了Element-UI上。最近,从鹏城回江城工作已三月有余,人算安顿,项目也行将上线,算是闲下来了,便想着实践下之前跟进的.net core,刚好把之前练手系统的后端给重构掉,于是,便有了此文。
2、技术栈
Asp.net core Web API + Autofac + EFCore + Element-UI + SqlServer2008R2
3、项目结构图
简要介绍下各工程:
Account:net core Web API类型,为前端提供Rest服务
Account.Common:公共工程,与具体业务无关,目前里边仅仅有两个类,自定义业务异常类及错误码枚举类
Account.Entity:这个不要问我
Account.Repository.Contract:仓储契约,一般用于隔离服务层与具体的仓储实现。做隔离的目的是因为与仓储实现直接依赖的数据访问技术可能有很多种,隔离后我们可以随时切换
Account.Repository.EF:仓储服务的EFCore实现,从工程名字应该很容易可以看出来,它实现Account.Repository.Contract。如果这里不想用EF,那我们可以随时新建个工程Account.Repository.Dapper,增加Dapper的实现
Account.Service.Contract:服务层契约,用来隔离Account工程与具体业务服务实现
Account.Service:业务服务,实现Account.Service.Contract这个业务服务层中的契约
Account.VueFE:这个与之前一样,静态前端站点,从项目工程图标上那个互联网球球还有名字中VueFE你就应该能猜出来
与之前那篇文章重点在Element-UI和Vue不同,这篇文章重点在后台,在.net core。
4、.net core与Autofac集成
1)Startup构造函数中添加Autofac配置文件
public Startup(IHostingEnvironment env) { var builder = new ConfigurationBuilder() .SetBasePath(env.ContentRootPath) .AddJsonFile("appsettings.json", optional: false, reloadOnChange: true) .AddJsonFile($"appsettings.{env.EnvironmentName}.json", optional: true) .AddJsonFile("autofac.json") .AddEnvironmentVariables(); Configuration = builder.Build(); }
红色部分便是Autofac的配置文件,具体内容如下:
{ "modules": [ { "type": "Account.Repository.EF.RepositoryModule, Account.Repository.EF" }, { "type": "Account.Service.ServiceModule, Account.Service" } ] }
这是一份模块配置文件。熟悉Autofac的都应该对这个概念比较熟悉,这种配置介于纯代码注册所有服务,以及纯配置文件注册所有服务之间,算是一个平衡,也是我最喜欢的方式。至于具体的模块内服务注册,待会儿讲解。
2)ConfigureServices适配
public IServiceProvider ConfigureServices(IServiceCollection services) { services.AddDbContext<AccountContext>(options => options.UseSqlServer(Configuration.GetConnectionString("DefaultConnection"), db => db.UseRowNumberForPaging())); services.AddCors(); // Add framework services. services.AddMvc(options => options.Filters.Add(typeof(CustomExceptionFilterAttribute))) .AddJsonOptions(options => options.SerializerSettings.DateFormatString = "yyyy-MM-dd HH:mm:ss"); var builder = new ContainerBuilder(); builder.Populate(services); var module = new ConfigurationModule(Configuration); builder.RegisterModule(module); this.Container = builder.Build(); return new AutofacServiceProvider(this.Container); }
这里有两个要注意的,其一,修改ConfigureServices返回类型:void => IServiceProvider ;其二,如红色部分,这个懒得说太细,太费事儿,总之跟.NET其他框架下的集成大同小异,没杀特别。
3)具体Autofac模块文件实现
项目中,业务服务实现和仓储实现这两个实现工程用到了Autofac模块化注册,这里分别看下。
此工程实现Account.Service.Contract业务服务契约,我们重点看ServiceModule这个模块注册类:
public class ServiceModule : Module { protected override void Load(ContainerBuilder builder) { //builder.RegisterType<ManifestService>().As<IManifestService>(); //builder.RegisterType<DailyService>().As<IDailyService>(); //builder.RegisterType<MonthlyService>().As<IMonthlyService>(); //builder.RegisterType<YearlyService>().As<IYearlyService>(); builder.RegisterAssemblyTypes(this.ThisAssembly) .Where(t => t.Name.EndsWith("Service")) .AsImplementedInterfaces() .InstancePerLifetimeScope(); } }
上述注释起来的代码,是最开始逐个服务注册的,后来,想偷点儿懒,就采取了官方的那种做法,既然都已经模块化这一步了,那还不更进一步。于是,这个模块类就成了你现在看到的这个样子,通俗点儿讲就是找出当前模块文件所在程序集中的所有类型注册为其实现的服务接口,注册模式为生命周期模式。这里跟旧版本的MVC或API有点儿不同的地方,旧版本用的是InstancePerRquest,但Core下面已经没有这种模式了,而是InstancePerLifetimeScope,起同样的效果。这里,我所有的服务类都以Service结尾。
Account.Repository.EF工程与此类似,不再赘述。
如此以来,控制器中,以及业务服务中,我们便可以遵循显示依赖模式来请求依赖组件,如下:
[Route("[controller]")] public class ManifestController : Controller { private readonly IManifestService _manifestService; public ManifestController(IManifestService manifestService) { _manifestService = manifestService; }
public class ManifestService : IManifestService { private readonly IManifestRepository _manifestRepository; public ManifestService(IManifestRepository manifestRepository) { _manifestRepository = manifestRepository; }
5、跨域设置
鉴于前后端分离,并分属两个不同的站点,前后端通信那就涉及到跨域问题,这里直接采用.net core内置的跨域解决方案,设置步骤如下:
1)ConfigureServices添加跨域相关服务
public IServiceProvider ConfigureServices(IServiceCollection services) { services.AddDbContext<AccountContext>(options => options.UseSqlServer(Configuration.GetConnectionString("DefaultConnection"), db => db.UseRowNumberForPaging())); services.AddCors();
2)Configure注册跨域中间件
public void Configure(IApplicationBuilder app, IHostingEnvironment env, ILoggerFactory loggerFactory, AccountContext context, IApplicationLifetime appLifetime) { loggerFactory.AddConsole(Configuration.GetSection("Logging")); loggerFactory.AddDebug(); app.UseCors(builder => builder.WithOrigins("http://localhost:65062") .AllowAnyHeader().AllowAnyMethod());
两点需要注意:其一,跨域中间件注册放在MVC路由注册之前,这个不用解释了吧;其二,红色部分设置你要允许的前端域名、标头及请求方法。这里允许http://localhost:65062(我的前端站点)、任意标头、任意请求方式
6、异常处理
按照个人以前惯例,异常处理采用异常过滤器,这里也不意外, 过滤器定义如下:
public class CustomExceptionFilterAttribute : ExceptionFilterAttribute { private readonly ILogger<CustomExceptionFilterAttribute> _logger; public CustomExceptionFilterAttribute(ILogger<CustomExceptionFilterAttribute> logger) { _logger = logger; } public override void OnException(ExceptionContext context) { Exception exception = context.Exception; JsonResult result = null; if (exception is BusinessException) { result = new JsonResult(exception.Message) { StatusCode = exception.HResult }; } else { result = new JsonResult("服务器处理出错") { StatusCode = 500 }; _logger.LogError(null, exception, "服务器处理出错", null); } context.Result = result; } }
简言之就是,判断操作方法中抛出的是什么异常,如果是由我们业务代码主动引发的业务级别异常,也就是类型为自定义BusinessException,则直接设置相应json结果状态码及 错误信息为我们引发异常时定义的状态码及错误信息;如果是框架或数据库操作失败引发的,被动式的异常,这种错误信息不应该暴露给前端,而且,这种服务器内部处理出错,理应统一设置状态码为500,还需要记录异常堆栈,如上的else分支所做。
之后,将此过滤器全局注册。Core中全局注册过滤器的德行如下:
public IServiceProvider ConfigureServices(IServiceCollection services) { services.AddDbContext<AccountContext>(options => options.UseSqlServer(Configuration.GetConnectionString("DefaultConnection"), db => db.UseRowNumberForPaging())); services.AddCors(); // Add framework services. services.AddMvc(options => options.Filters.Add(typeof(CustomExceptionFilterAttribute))) .AddJsonOptions(options => options.SerializerSettings.DateFormatString = "yyyy-MM-dd HH:mm:ss");
顺便说下那个AddJsonOptions的,大家应该经常遇到时间字符串表示中有个T吧,是不是很蛋疼,这句话就是解决这个问题的。
7、具体请求解析
请求流经的处理流程如下图:
由上到下的顺序,线上边是组件之间通信或依赖经由的协议或契约
我们以其中消费明细管理为例,将上图中工程变为具体组件, 具体请求处理流程就变成了:
鉴于具体服务实现、数据访问等跟之前基于asp.net web api的实现已经有了很大不同,这里还是分析下各CRUD方法吧。
1)路由
基于WebAPI或者说Rest的路由,我一向倾向于用特性路由,而非MVC默认路由,因为更灵活,也更容易符合Rest模式。来看具体控制器:
旧版本中,我们只能在控制器层面使用RoutePrefix特性,.NET CORE中已经不再有RoutePrefix,直接上Route。而且,注意路由模板中那个[controller],这是一个控制器占位符,具体运行时会被控制器名称替换,比写死爽多了吧。接下来,看控制器方法层面:
大家看到各CRUD操作上的特性标记没有。老WebAPI中,是需要通过Route来设置,具体请求方法约束需要单独通过类似HttpGet、HttpPut等来约束,而.NET CORE中,可以合二为一,路由设置和请求方法约束一起搞定。当然,你依然可以按照老方式来玩儿,没毛病,无非就是多写一行代码,累赘点儿而已。实际上,路由中不光可以有控制器占位符,还可以有操作占位符,运行时会被操作名称代替,但这里是Rest服务,不是MVC终结点,所以我没有添加控制器方法占位符[action]。
另外,注意看添加和编辑,以添加为例:
[HttpPost("")] public IActionResult Add([FromBody]Manifest manifest) { manifest = _manifestService.AddManifest(manifest); return CreatedAtRoute(new { ID = manifest.ID }, manifest); }
看到那个红色FromBody特性标记没有?起初,我是没有添加这个特性的,因为根据旧版本的经验,前端设置Content-type为json,后端Put,POST实体参数那不就是自动绑定么。.NET CORE中不行了,必须明确指定,参数来源于哪儿,否则,绑定失败,而且不报错,更操蛋的,这个包需要我们单独引用,包名是Microsoft.AspNetCore.Mvc.Core,默认MVC工程是没有引用的。
2)分页查询
来看日消费明细吧:
public async Task<PaginatedList<Manifest>> GetManifests(DateTime start, DateTime end, int pageIndex, int pageSize) { var source = _context.Manifests.Where(x => x.Date >= start && x.Date < new DateTime(end.Year, end.Month, end.Day).AddDays(1)); int count = await source.CountAsync(); List<Manifest> manifests = null; if (count > 0) { manifests = await source.OrderBy(x => x.Date).Skip((pageIndex - 1) * pageSize).Take(pageSize).ToListAsync(); } return new PaginatedList<Manifest>(pageIndex, pageSize, count, manifests ?? new List<Manifest>()); }
典型的EF分页查询,先获取符合条件总记录数,然后排序并取指定页数据,没毛病。
日消费清单也类似,但关于月清单和年清单,这里要多说下。 月清单和年清单都是统计的日消费清单Daily,具体Daily又是由日消费明细Manifest支撑的。
来看下月消费清单的查询:
public async Task<PaginatedList<Monthly>> GetMonthlys(string start, string end, int pageIndex, int pageSize) { var source = _context.Dailys .Where(x => x.Date >= DateTime.Parse(start) && x.Date <= DateTime.Parse(end).AddMonths(1).AddSeconds(-1)) .GroupBy(x => x.Date.ToString("yyyy-MM"), (k, v) => new Monthly { ID = Guid.NewGuid().ToString(), Month = k, Cost = v.Sum(x => x.Cost) }); int count = await source.CountAsync(); List<Monthly> months = null; if (count > 0) { months = await source.OrderBy(x => x.Month).Skip((pageIndex - 1) * pageSize).Take(pageSize).ToListAsync(); } return new PaginatedList<Monthly>(pageIndex, pageSize, count, months ?? new List<Monthly>()); }
大家注意红色部分,日消费清单按照x.Date.ToString("yyyy-MM")分组,然后统计各分组合计构建出月消费明细代表。我本来以为这里会生成终极统计sql到数据库执行,可跟踪EFCore执行,发现并没有,而是先从数据库取出所有日消费明细,之后内存中进行分组统计,坑爹。。。这里,给下之前旧版本实现月度统计的sql吧:
SELECT NEWID() ID, ROW_NUMBER() OVER(ORDER BY CONVERT(CHAR(7), DATE, 120)) RowNum, CONVERT(CHAR(7), DATE, 120) MONTH, SUM(COST) COST FROM DAILY WHERE CONVERT(CHAR(7), DATE, 120) BETWEEN @START AND @END GROUP BY CONVERT(CHAR(7), DATE, 120)
本以为EFCore会生成类似sql,可是并没有,可能是因为那个分组非直接数据库字段而是做了特定映射,比如x.Date.ToString("yyyy-MM")吧。很明显,手动写统计sql的方式效率要高出很多,这里为什么没有手写,还是用了EFCore呢?两个原因吧,其一,我想练习下EFCore,其二,这样可以做到随意切换数据库,我不想在代码层面引入过多跟具体数据库有关的语法。
3)消费明细添加
public Manifest AddManifest(Manifest manifest) { _context.Add(manifest); var daily = _context.Dailys.FirstOrDefault(x => x.Date.Date == manifest.Date.Date); if (daily != null) { daily.Cost += manifest.Cost; _context.Update(daily); } else { daily = new Daily { ID = Guid.NewGuid().ToString(), Date = manifest.Date, Cost = manifest.Cost }; _context.Add(daily); } _context.SaveChanges(); return manifest; }
这里有2点啰嗦下,其一,如果看过我写的旧版本的后端,就会发现,DAL中添加消费明细就只有一个往Manifest表中添加消费明细记录的操作,日消费清单Daily表的数据实际上是由SQLserver触发器来自动维护的。这里,CodeFirst生成数据库后,我没添加任何触发器,直接在代码层面去维护,也是想做到应用层面对底层存储无感知。其二,这里直接就_context.SaveChanges();了,这是多次数据库操作啊,你的事务呢?需要说明,EFCore目前是自动实现事务的,所以传统的工作单元啊,应用层面的非分布式数据库事务,已经不用我们操心了。
8、总结
至此,后端的一个初步重构算是完成了,文章中提到的东西,大家如果有更好的实践,望不吝赐教告诉我,共同进步。建议大家看的时候,可以结合新旧两个不同版本,看下路由,跨域,数据访问,DI等的异同,加深印象。
9、源码地址
https://github.com/KINGGUOKUN/Account/tree/master/Account.Core
顺便请教各位一个问题,我的解决方案中,有些工程有锁标记,有些么有,如下图,没天理,谁知道是什么鬼情况啊?
10、后续计划
1)数据库 SQLServer =》 MySQL
2)部署至Linux。机器破旧,09年的,ThinkPad X201i,都不敢装虚拟机,关键是还是个穷逼,你说咋整吧。。。
3)基于认证中间件及授权过滤器,做API鉴权。授权基于传统三表权限(用户,角色,权限)
4)分布式缓存、会话缓存及负载均衡