從頭編寫 asp.net core 2.0 web api 基礎框架 (1)

solenovex發表於2017-10-08

工具:

1.Visual Studio 2017 V15.3.5+

2.Postman (Chrome的App)

3.Chrome (最好是)

關於.net core或者.net core 2.0的相關知識就不介紹了, 這裡主要是從頭編寫一個asp.net core 2.0 web api的基礎框架.

我一直在關注asp.net core 和 angular 2/4, 並在用這對開發了一些比較小的專案. 現在我感覺是時候使用這兩個技術去為企業開發大一點的專案了, 由於企業有時候需要SSO(單點登入), 所以我一直在等待Identity Server4以及相關庫的正式版, 現在匹配2.0的RC版已經有了, 所以這個可以開始編寫了.

這個系列就是我從頭開始建立我自己的基於asp.net core 2.0 web api的後臺api基礎框架過程, 估計得分幾次才能寫完. 如果有什麼地方錯的, 請各位指出!!,謝謝.

 

建立專案:

1.選擇asp.net core web application.

2.選擇.net core, asp.net core 2.0, 然後選擇Empty (因為是從頭開始):

下面看看專案生成的程式碼:

Program.cs

namespace CoreBackend.Api
{
    public class Program
    {
        public static void Main(string[] args)
        {
            BuildWebHost(args).Run();
        }

        public static IWebHost BuildWebHost(string[] args) =>
            WebHost.CreateDefaultBuilder(args)
                .UseStartup<Startup>()
                .Build();
    }
}

這個Program是程式的入口, 看起來很眼熟, 是因為asp.net core application實際就是控制檯程式(console application).

它是一個呼叫asp.net core 相關庫的console application. 

Main方法裡面的內容主要是用來配置和執行程式的.

因為我們的web程式需要一個宿主, 所以 BuildWebHost這個方法就建立了一個WebHostBuilder. 而且我們還需要Web Server.

看一下WebHost.CreateDefaultBuilder(args)的原始碼:

public static IWebHostBuilder CreateDefaultBuilder(string[] args)
        {
            var builder = new WebHostBuilder()
                .UseKestrel()
                .UseContentRoot(Directory.GetCurrentDirectory())
                .ConfigureAppConfiguration((hostingContext, config) =>
                {
                    var env = hostingContext.HostingEnvironment;

                    config.AddJsonFile("appsettings.json", optional: true, reloadOnChange: true)
                          .AddJsonFile($"appsettings.{env.EnvironmentName}.json", optional: true, reloadOnChange: true);

                    if (env.IsDevelopment())
                    {
                        var appAssembly = Assembly.Load(new AssemblyName(env.ApplicationName));
                        if (appAssembly != null)
                        {
                            config.AddUserSecrets(appAssembly, optional: true);
                        }
                    }

                    config.AddEnvironmentVariables();

                    if (args != null)
                    {
                        config.AddCommandLine(args);
                    }
                })
                .ConfigureLogging((hostingContext, logging) =>
                {
                    logging.AddConfiguration(hostingContext.Configuration.GetSection("Logging"));
                    logging.AddConsole();
                    logging.AddDebug();
                })
                .UseIISIntegration()
                .UseDefaultServiceProvider((context, options) =>
                {
                    options.ValidateScopes = context.HostingEnvironment.IsDevelopment();
                });

            return builder;
        }

asp.net core 自帶了兩種http servers, 一個是WebListener, 它只能用於windows系統, 另一個是kestrel, 它是跨平臺的.

kestrel是預設的web server, 就是通過UseKestrel()這個方法來啟用的.

但是我們開發的時候使用的是IIS Express, 呼叫UseIISIntegration()這個方法是啟用IIS Express, 它作為Kestrel的Reverse Proxy server來用.

如果在windows伺服器上部署的話, 就應該使用IIS作為Kestrel的反向代理伺服器來管理和代理請求.

如果在linux上的話, 可以使用apache, nginx等等的作為kestrel的proxy server.

當然也可以單獨使用kestrel作為web 伺服器, 但是使用iis作為reverse proxy還是由很多有點的: 例如,IIS可以過濾請求, 管理證書, 程式崩潰時自動重啟等.

UseStartup<Startup>(), 這句話表示在程式啟動的時候, 我們會呼叫Startup這個類.

Build()完之後返回一個實現了IWebHost介面的例項(WebHostBuilder), 然後呼叫Run()就會執行Web程式, 並且阻止這個呼叫的執行緒, 直到程式關閉.

BuildWebHost這個lambda表示式最好不要整合到Main方法裡面, 因為Entity Framework 2.0會使用它, 如果把這個lambda表示式去掉之後, Add-Migration這個命令可能就不好用了!!!

Startup.cs

namespace CoreBackend.Api
{
    public class Startup
    {
        // This method gets called by the runtime. Use this method to add services to the container.
        // For more information on how to configure your application, visit https://go.microsoft.com/fwlink/?LinkID=398940
        public void ConfigureServices(IServiceCollection services)
        {
        }

        // This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
        public void Configure(IApplicationBuilder app, IHostingEnvironment env)
        {
            if (env.IsDevelopment())
            {
                app.UseDeveloperExceptionPage();
            }

            app.Run(async (context) =>
            {
                await context.Response.WriteAsync("Hello World!");
            });
        }
    }
}

其實Startup算是程式真正的切入點.

ConfigureServices方法是用來把services(各種服務, 例如identity, ef, mvc等等包括第三方的, 或者自己寫的)加入(register)到container(asp.net core的容器)中去, 並配置這些services. 這個container是用來進行dependency injection的(依賴注入). 所有注入的services(此外還包括一些框架已經註冊好的services) 在以後寫程式碼的時候, 都可以將它們注入(inject)進去. 例如上面的Configure方法的引數, app, env, loggerFactory都是注入進去的services.

Configure方法是asp.net core程式用來具體指定如何處理每個http請求的, 例如我們可以讓這個程式知道我使用mvc來處理http請求, 那就呼叫app.UseMvc()這個方法就行. 但是目前, 所有的http請求都會導致返回"Hello World!".

這幾個方法的呼叫順序: Main -> ConfigureServices -> Configure

請求管道和中介軟體(Request Pipeline, Middleware)

請求管道: 那些處理http requests並返回responses的程式碼就組成了request pipeline(請求管道).

中介軟體: 我們可以做的就是使用一些程式來配置那些請求管道 request pipeline以便處理requests和responses. 比如處理驗證(authentication)的程式, 連MVC本身就是個中介軟體(middleware).

每層中介軟體接到請求後都可以直接返回或者呼叫下一個中介軟體. 一個比較好的例子就是: 在第一層呼叫authentication驗證中介軟體, 如果驗證失敗, 那麼直接返回一個表示請求未授權的response.

app.UseDeveloperExceptionPage(); 就是一個middleware, 當exception發生的時候, 這段程式就會處理它. 而判斷env.isDevelopment() 表示, 這個middleware只會在Development環境下被呼叫.

可以在專案的屬性Debug頁看到這個設定: 

需要注意的是這個環境變數Development和VS裡面的Debug Build沒有任何關係.

在正式環境中, 我們遇到exception的時候, 需要捕獲並把它記錄(log)下來, 這時候我們應該使用這個middleware: Exception Handler Middleware, 我們可以這樣呼叫它:

            if (env.IsDevelopment())
            {
                app.UseDeveloperExceptionPage();
            }
            else
            {
                app.UseExceptionHandler();
            }

UseExceptionHandler是可以傳引數的, 但暫時先這樣, 我們在app.Run方法裡拋一個異常, 然後執行程式, 在Chrome裡按F12就會發現有一個(或若干個, 多少次請求, 就有多少個錯誤)500錯誤.

用來建立 Web Api的middleware:

 原來的.net使用asp.net web api 和 asp.net mvc 分別來建立 web api和mvc專案. 但是 asp.net core mvc把它們整合到了一起.

MVC Pattern

model-view-controller 它的定義是: MVC是一種用來實現UI的架構設計模式. 但是網上有很多解釋, 有時候有點分不清到底是幹什麼的. 但是它肯定有這幾個有點: 鬆耦合, Soc(Separation of concerns), 易於測試, 可複用性強等.

但是MVC絕對不是完整的程式架構, 在一個典型的n層架構裡面(presentation layer 展示層, business layer 業務層, data access layer資料訪問層, 還有服務處), MVC通常是展示層的. 例如angular就是一個客戶端的MVC模式.

在Web api裡面的View就是指資料或者資源的展示, 通常是json.

註冊並使用MVC

因為asp.net core 2.0使用了一個大而全的metapackage, 所以這些基本的services和middleware是不需要另外安裝的.

首先, 在ConfigureServices裡面向Container註冊MVC: services.AddMvc();

        public void ConfigureServices(IServiceCollection services)
        {
            services.AddMvc(); // 註冊MVC到Container
        }

然後再Configure裡面告訴程式使用mvc中介軟體:

        public void Configure(IApplicationBuilder app, IHostingEnvironment env)
        {
            if (env.IsDevelopment())
            {
                app.UseDeveloperExceptionPage();
            }
            else
            {
                app.UseExceptionHandler();
            }

            app.UseMvc();

注意順序, 應該在處理異常的middleware後邊呼叫app.UseMvc(), 所以處理異常的middleware可以在把request交給mvc之間就處理異常, 更總要的是它還可以捕獲並處理返回MVC相關程式碼執行中的異常.

然後別忘了把app.Run那部分程式碼去掉. 然後改回到Develpment環境, 跑一下, 試試效果:

Chrome顯示了一個空白頁, 按F12, 顯示了404 Not Found錯誤.

這是因為我只新增了MVC middleware, 但是它啥也沒做, 也沒有找到任何可用於處理請求的程式碼, 所以我們要新增Controller來返回資料/資源等等.

Asp.net Core 2 Metapackage 和 Runtime Store

Asp.net core 2 metapackage, asp.net core 2.0開始, 所有必須的和常用的庫也包括少許第三方庫都被整和到了這個大而全的asp.net core 2 metapackage裡面, 所以開發者就不必自己挨個庫安裝也沒有版本匹配問題了.

Runtime Store, 有點像以前的GAC, 在系統裡有一個資料夾裡面包含所有asp.net core 2程式需要執行的庫(我電腦的是: C:\Program Files\dotnet\store\x64\netcoreapp2.0), 每個在這臺電腦上執行的asp.net core 2應用只需呼叫這些庫即可. 

它的優點是:

  1. 部署快速, 不需要部署這裡麵包含的庫;
  2. 節省硬碟空間, 多個應用程式都使用同一個store, 而不必每個程式的資料夾裡面都部署這些庫檔案. 
  3. 程式啟動更快一些. 因為這些庫都是預編譯好的.

缺點是: 伺服器上需要安裝.net core 2.0

但是, 也可以不引用Runtime Store的庫, 自己在部署的時候挨個新增依賴的庫.

Controller

首先建立一個Controllers目錄, 然後建立一個ProductController.cs, 它需要繼承Microsoft.AspNetCore.Mvc.Controller

我們先建立一個方法返回一個Json的結果.

先建立一個Dto(Data Transfer Object) Product:

namespace CoreBackend.Api.Dtos
{
    public class Product
    {
        public int Id { get; set; }
        public string Name { get; set; }
        public float Price { get; set; }
    }
}

然後在Controller裡面寫這個Get方法:

namespace CoreBackend.Api.Controllers
{
    public class ProductController: Controller
    {
        public JsonResult GetProducts()
        {
            return new JsonResult(new List<Product>
            {
                new Product
                {
                    Id = 1,
                    Name = "牛奶",
                    Price = 2.5f
                },
                new Product
                {
                    Id = 2,
                    Name = "麵包",
                    Price = 4.5f
                }
            });
        }
    }
}

然後執行, 並使用postman來進行請求:

請求的網址返回404 Not Found, 因為還沒有配置路由 Routing, 所以MVC不知道如何處理/對映這些URI.

Routing 路由

路由有兩種方式: Convention-based (按約定), attribute-based(基於路由屬性配置的). 

其中convention-based (基於約定的) 主要用於MVC (返回View或者Razor Page那種的).

Web api 推薦使用attribute-based.

這種基於屬性配置的路由可以配置Controller或者Action級別, uri會根據Http method然後被匹配到一個controller裡具體的action上.

常用的Http Method有:

  • Get, 查詢, Attribute: HttpGet, 例如: '/api/product', '/api/product/1'
  • POST, 建立, HttpPost, '/api/product'
  • PUT 整體修改更新 HttpPut, '/api/product/1'
  • PATCH 部分更新, HttpPatch, '/api/product/1'
  • DELETE 刪除, HttpDelete, '/api/product/1

還有一個Route屬性(attribute)也可以用於Controller層, 它可以控制action級的URI字首.

namespace CoreBackend.Api.Controllers
{
    //[Route("api/product")]
    [Route("api/[controller]")]
    public class ProductController: Controller
    {
        [HttpGet]
        public JsonResult GetProducts()
        {
            return new JsonResult(new List<Product>
            {
                new Product
                {
                    Id = 1,
                    Name = "牛奶",
                    Price = 2.5f
                },
                new Product
                {
                    Id = 2,
                    Name = "麵包",
                    Price = 4.5f
                }
            });
        }
    }
}

使用[Route("api/[controller]")], 它使得整個Controller下面所有action的uri字首變成了"/api/product", 其中[controller]表示XxxController.cs中的Xxx(其實是小寫).

也可以具體指定, [Route("api/product")], 這樣做的好處是, 如果ProductController重構以後改名了, 只要不改Route裡面的內容, 那麼請求的地址不會發生變化.

然後在GetProducts方法上面, 寫上HttpGet, 也可以寫HttpGet(). 它裡面還可以加引數,例如: HttpGet("all"), 那麼這個Action的請求的地址就變成了 "/api/product/All".

執行結果:

我們把獲取資料的程式碼整理成一個ProductService, 然後保證程式執行的時候, 操作的是同一批資料:

namespace CoreBackend.Api.Services
{
    public class ProductService
    {
        public static ProductService Current { get; } = new ProductService();

        public List<Product> Products { get; }

        private ProductService()
        {
            Products = new List<Product>
            {
                new Product
                {
                    Id = 1,
                    Name = "牛奶",
                    Price = 2.5f
                },
                new Product
                {
                    Id = 2,
                    Name = "麵包",
                    Price = 4.5f
                },
                new Product
                {
                    Id = 3,
                    Name = "啤酒",
                    Price = 7.5f
                }
            };
        }
    }
}

然後修改一下Controller裡面的程式碼:

namespace CoreBackend.Api.Controllers
{
    [Route("api/[controller]")]
    public class ProductController: Controller
    {
        [HttpGet]
        public JsonResult GetProducts()
        {
            return new JsonResult(ProductService.Current.Products);
        }
    }
}

也是同樣的執行效果.

再寫一個查詢單筆資料的方法:

        [Route("{id}")]
        public JsonResult GetProduct(int id)
        {
            return new JsonResult(ProductService.Current.Products.SingleOrDefault(x => x.Id == id));
        }

這裡Route引數裡面的{id}表示該action有一個引數名字是id. 這個action的地址是: "/api/product/{id}"

測試一下:

如果請求一個id不存在的資料:

Status code還是200, 內容是null. 因為框架找到了匹配uri的action, 所以不會返回404, 但是我們如果找不到資料的話, 應該返回404錯誤才比較好.

Status code

http status code 是reponse的一部分, 它提供了這些資訊: 請求是否成功, 失敗的原因. 

web api 能涉及到的status codes主要是這些:

200: OK

201: Created, 建立了新的資源

204: 無內容 No Content, 例如刪除成功

400: Bad Request, 指的是客戶端的請求錯誤.

401: 未授權 Unauthorized.

403: 禁止操作 Forbidden. 驗證成功, 但是沒法訪問相應的資源

404: Not Found 

409: 有衝突 Conflict.

500: Internal Server Error, 伺服器發生了錯誤.

返回Status Code

目前我們返回的JsonResult繼承與ActionResult, ActionResult實現了IActionResult介面.

因為web api不一定返回的都是json型別的資料, 也不一定只返回一堆json(可能還要包含其他內容). 所以JsonResult並不合適作為Action的返回結果.

例如: 我們想要返回資料和Status Code, 那麼可以這樣做:

        [HttpGet]
        public JsonResult GetProducts()
        {
            var temp = new JsonResult(ProductService.Current.Products)
            {
                StatusCode = 200
            };
            return temp;
        }

但是每個方法都這麼寫太麻煩了.

asp.net core 內建了很多方法都可以返回IActionResult.

Ok, NotFound, BadRequest等等.

所以改一下方法:

namespace CoreBackend.Api.Controllers
{
    [Route("api/[controller]")]
    public class ProductController : Controller
    {
        [HttpGet]
        public IActionResult GetProducts()
        {
            return Ok(ProductService.Current.Products);
        }

        [Route("{id}")]
        public IActionResult GetProduct(int id)
        {
            var product = ProductService.Current.Products.SingleOrDefault(x => x.Id == id);
            if (product == null)
            {
                return NotFound();
            }
            return Ok(product);
        }
    }
}

現在, 請求id不存在的資料時, 就返回404了.

如果我們用chrome直接進行這個請求, 它的效果是這樣的:

StatusCode Middleware

asp.net core 有一個 status code middleware, 使用一下這個middleware看看效果:

        public void Configure(IApplicationBuilder app, IHostingEnvironment env)
        {
            if (env.IsDevelopment())
            {
                app.UseDeveloperExceptionPage();
            }
            else
            {
                app.UseExceptionHandler();
            }

            app.UseStatusCodePages(); // !!!

            app.UseMvc();
        }

現在更友好了一些.

子資源 Child Resources

有時候, 兩個model之間有主從關係, 會根據主model來查詢子model.

先改一下model: 新增一個Material作為Product子model. 並在Product裡面新增一個集合導航屬性.

namespace CoreBackend.Api.Dtos
{
    public class Product
    {
        public int Id { get; set; }
        public string Name { get; set; }
        public float Price { get; set; }
        public ICollection<Material> Materials { get; set; }
    }

    public class Material
    {
        public int Id { get; set; }
        public int Name { get; set; }
    }
}

改下ProductService:

namespace CoreBackend.Api.Services
{
    public class ProductService
    {
        public static ProductService Current { get; } = new ProductService();

        public List<Product> Products { get; }

        private ProductService()
        {
            Products = new List<Product>
            {
                new Product
                {
                    Id = 1,
                    Name = "牛奶",
                    Price = 2.5f,
                    Materials = new List<Material>
                    {
                        new Material
                        {
                            Id = 1,
                            Name = ""
                        },
                        new Material
                        {
                            Id = 2,
                            Name = "奶粉"
                        }
                    }
                },
                new Product
                {
                    Id = 2,
                    Name = "麵包",
                    Price = 4.5f,
                    Materials = new List<Material>
                    {
                        new Material
                        {
                            Id = 3,
                            Name = "麵粉"
                        },
                        new Material
                        {
                            Id = 4,
                            Name = ""
                        }
                    }
                },
                new Product
                {
                    Id = 3,
                    Name = "啤酒",
                    Price = 7.5f,
                    Materials = new List<Material>
                    {
                        new Material
                        {
                            Id = 5,
                            Name = "麥芽"
                        },
                        new Material
                        {
                            Id = 6,
                            Name = "地下水"
                        }
                    }
                }
            };
        }
    }
}
View Code

建立子Controller

MaterialController:

namespace CoreBackend.Api.Controllers
{
    [Route("api/product")] // 和主Model的Controller字首一樣
    public class MaterialController : Controller
    {
        [HttpGet("{productId}/materials")]
        public IActionResult GetMaterials(int productId)
        {
            var product = ProductService.Current.Products.SingleOrDefault(x => x.Id == productId);
            if (product == null)
            {
                return NotFound();
            }
            return Ok(product.Materials);
        }

        [HttpGet("{productId}/materials/{id}")]
        public IActionResult GetMaterial(int productId, int id)
        {
            var product = ProductService.Current.Products.SingleOrDefault(x => x.Id == productId);
            if (product == null)
            {
                return NotFound();
            }
            var material = product.Materials.SingleOrDefault(x => x.Id == id);
            if (material == null)
            {
                return NotFound();
            }
            return Ok(material);
        }
    }
}

測試一下, 很成功:

結果的格式

asp.net core 2.0 預設返回的結果格式是Json, 並使用json.net對結果預設做了camel case的轉化(大概可理解為首字母小寫). 

這一點與老.net web api 不一樣, 原來的 asp.net web api 預設不適用任何NamingStrategy, 需要手動加上camelcase的轉化.

我很喜歡這樣, 因為大多數前臺框架例如angular等都約定使用camel case.

如果非得把這個規則去掉, 那麼就在configureServices裡面改一下:

        public void ConfigureServices(IServiceCollection services)
        {
            services.AddMvc()
                .AddJsonOptions(options =>
                {
                    if (options.SerializerSettings.ContractResolver is DefaultContractResolver resolver)
                    {
                        resolver.NamingStrategy = null;
                    }
                });
        }

現在就是這樣的結果了:

但是還是預設的比較好.

內容協商 Content Negotiation

如果 web api提供了多種內容格式, 那麼可以通過Accept Header來選擇最好的內容返回格式: 例如:

application/json, application/xml等等

如果設定的格式在web api裡面沒有, 那麼web api就會使用預設的格式.

asp.net core 預設提供的是json格式, 也可以配置xml等格式.

目前只考慮 Output formatter, 就是返回的內容格式.

試試: json:

xml:

設定header為xml後,返回的還是json, 這是因為asp.net core 預設只實現了json.

可以在ConfigureServices裡面修改Mvc的配置來新增xml格式:

        public void ConfigureServices(IServiceCollection services)
        {
            services.AddMvc()
                .AddMvcOptions(options =>
                {
                    options.OutputFormatters.Add(new XmlDataContractSerializerOutputFormatter());
                });
        }

然後試試:

首先不寫Accept Header:

然後試試accept xml :

 

先寫這些..............................

相關文章