.net core web api + Autofac + EFCore 個人實踐


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)分布式緩存、會話緩存及負載均衡


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM