我的环境

1
2
3
OS type:mac
Software:vscode
Dotnet core version:2.0/3.1

dotnet sdk下载地址:https://dotnet.microsoft.com/download/dotnet-core/2.0

准备

先到上面提供的下载地址,下载对应平台的 dotnet 装上,然后在命令行窗口输入 dotnet --version 查看输出是否安装成功。

然后,安装 visual studio code,安装之后还需要安装C# 拓展,要不然每次打开 cs 文件都会报错。

创建项目

新建一个空目录,例如mvc-test

使用命令 dotnet new 查看可以新建的项目类型:

第一次尝试,使用 ASP.NET Core Empty 就可以,代号是 web,使用命令dotnet new web 就可以新建一个空项目,项目的名称就是当前目录的名字mvc-test

项目结构与默认配置

目录主要结构和文件功能如下:

Program.cs是程序的主类,Main函数在这里定义,内容大致可以这么理解:

CreateDefaultBuilder函数会使用默认的方法载入配置,例如通过读取 launchSettings.json 确定当前的发布环境:

webhost通过 ASPNETCORE_ENVIRONMENT 读取发布环境,然后会读取对应的配置文件,Development对应 appsettings.Development.jsonProduction 对应appsettings.json

appsettings文件是整个 web 应用的配置文件,如果 web 应用需要使用某个全局变量,可以配置到这个文件里面去。

webhost在运行前会通过 Startup 类,进行一些中间件的配置和注册,以及进行客户端的响应内容设置:

注:dotnet core 3版本里,取消了 WebHost,使用Host 以更通用的方式进行程序托管。

dotnet core 3 Program.cs

1
2
3
4
5
6
7
public static Void Main(string[] args)
{
Host.CreateDefaultBuilder(args).ConfigureWebHostDefaults(builder =>
{
builder.UseStartup<Startup>();
}).Build().Run();
}

获取配置文件中的值

修改 launingSettings.json 中设置的发布环境对应的配置文件,例如 appsetttings.Delelopment.json 内容,添加一个 Welcome 字段配置项,如下:

1
2
3
4
5
6
7
8
9
10
{
"Logging": {
"LogLevel": {
"Default": "Debug",
"System": "Information",
"Microsoft": "Information"
}
},
"Welcome": "Hello from appsettings.json!!"
}

修改 Startup.cs 文件,添加 IConfiguration config 参数,.net core内部会将配置文件内容映射到这个变量:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
/// <summary>
/// 注册应用程序所需的服务
/// </summary>
public void ConfigureServices(IServiceCollection services)
{
}

/// <summary>
/// 注册管道中间件
/// </summary>
public void Configure(IApplicationBuilder app, IHostingEnvironment env, IConfiguration config)
{
// 开发环境,使用开发者异常界面
if (env.IsDevelopment())
{
app.UseDeveloperExceptionPage();
}

var welcome = config["Welcome"];

// Run 一般放在管道末尾,运行完毕之后直接终止请求,所以在其后注册的中间件,将不会被执行
app.Run(async (context) =>
{
await context.Response.WriteAsync(welcome);
});
}

在终端中使用命令 dotnet run 可以运行这个 web 应用:

浏览器访问 http://localhost:5000,可以看到已经成功获取到Welcome 配置项的值:

日志打印

通过 ILogger 实现控制台日志的打印:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
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,
IConfiguration config,
ILogger<Startup> logger)
{
if (env.IsDevelopment())
{
app.UseDeveloperExceptionPage();
}

var welcome = config["Welcome"];

logger.LogInformation(welcome);

app.Run(async (context) =>
{
await context.Response.WriteAsync(welcome);
});
}

ILogger使用的时候需要指定打印日志的类名Startup,最终打印效果如下:

服务注册

上面的 IConfiguration 可以直接使用,是因为 IConfiguration 服务已经自动注册过了。

对于自定义的服务,可以在 ConfigureServices 中注册,例如自定义一个服务 WelcomeService,项目目录下新建两个文件IWelcomeService.csWelcomeService.cs,内容如下:

1
2
3
4
5
6
7
8
9
10
11
/* IWelcomeService.cs
*
* 该接口类定义了一个 getMessage 方法。
*/
namespace mvc_test
{
public interface IWelcomeService
{
string getMessage();
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/* WelcomeService.cs
*
* 该类实现了 getMessage 方法。
*/
namespace mvc_test
{
public class WelcomeService : IWelcomeService
{
int c = 0;
public string getMessage()
{
c++;
return "Hello from IWelcomeService Interface!!!" + c.ToString();
}
}
}

然后在 ConfigureServices 中注册服务:

1
2
3
4
public void ConfigureServices(IServiceCollection services)
{
services.AddSingleton<IWelcomeService, WelcomeService>();
}

然后在 Configure 中使用的时候需要传参:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public void Configure(
IApplicationBuilder app,
IHostingEnvironment env,
IConfiguration config,
ILogger<Startup> logger,
IWelcomeService welcomeService)
{
if (env.IsDevelopment())
{
app.UseDeveloperExceptionPage();
}

//var welcome = config["Welcome"];
var welcome = welcomeService.getMessage();

logger.LogInformation(welcome);

// Run 一般放在管道末尾,运行完毕之后直接终止请求,所以在其后注册的中间件,将不会被执行
app.Run(async (context) =>
{
await context.Response.WriteAsync(welcome);
});
}

运行后结果:

这个例子中,注册服务使用的函数是 AddSingleton,服务的生命周期除了Singleton,还有其他两个模式:ScopedTransient

这三个模式的区别:

  • Transient:瞬态模式,服务在每次请求时被创建,它最好被用于轻量级无状态服务;
  • Scoped:作用域模式,服务在每次请求时被创建,整个请求过程中都贯穿使用这个创建的服务。比如 Web 页面的一次请求;
  • Singleton:单例模式,服务在第一次请求时被创建,其后的每次请求都用这个已创建的服务;

参考资料:

初始学习使用 AddSingleton 就行了。

中间件和管道

中间件是一种用来处理请求和响应的组件,一个 web 应用可以有多个中间件,这些中间件共同组成一个管道,每次请求消息进入管道后都会按中间件顺序处理对应请求数据,然后响应结果原路返回:

参考资料:

内置中间件的使用:处理静态文件访问请求

新建一个目录 wwwroot,目录下新建index.html 文件:

1
2
3
4
5
6
7
8
<html>
<head>
<title>TEST</title>
</head>
<body>
<h1>Hello from index.html!!!</h1>
</body>
</html>

使用之前的代码,dotnet run运行之后访问http://localhost:5000/index.html,发现还是之前的结果,并没有访问到index.html

这时候需要使用中间件 StaticFiles 来处理静态文件的请求,修改 Startup.cs 的部分内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public void Configure(
IApplicationBuilder app,
IHostingEnvironment env,
IConfiguration config,
ILogger<Startup> logger,
IWelcomeService welcomeService)
{
if (env.IsDevelopment())
{
app.UseDeveloperExceptionPage();
}

app.UseStaticFiles();

//var welcome = config["Welcome"];

app.Run(async (context) =>
{
var welcome = welcomeService.getMessage();
logger.LogInformation(welcome);
await context.Response.WriteAsync(welcome);
});
}

重新启动后可正常访问到index.html

前面讲到请求进入管道之后是安装中间件添加顺序处理的请求,如果当前中间件不能处理,才会交给下一个中间件,所以可以尝试一下将上面的代码调整一下顺序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public void Configure(
IApplicationBuilder app,
IHostingEnvironment env,
IConfiguration config,
ILogger<Startup> logger,
IWelcomeService welcomeService)
{
if (env.IsDevelopment())

app.UseDeveloperExceptionPage();
}

//var welcome = config["Welcome"];

app.Run(async (context) =>
{
var welcome = welcomeService.getMessage();
logger.LogInformation(welcome);
await context.Response.WriteAsync(welcome);
});

app.UseStaticFiles();
}

可以看到 StaticFiles 放到了最后,这样的话因为 index.html 请求会先到 Run 的地方,直接返回了,所以不能进入到 StaticFiles 里,访问得到的内容就是:

通过 StaticFiles 可以成功访问到 index.html,但是如果想要index.html 成为默认网站主页,需要使用中间件DefaultFiles,修改上面代码为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public void Configure(
IApplicationBuilder app,
IHostingEnvironment env,
IConfiguration config,
ILogger<Startup> logger,
IWelcomeService welcomeService)
{
if (env.IsDevelopment())
{
app.UseDeveloperExceptionPage();
}

app.UseDefaultFiles();
app.UseStaticFiles();

//var welcome = config["Welcome"];

app.Run(async (context) =>
{
var welcome = welcomeService.getMessage();
logger.LogInformation(welcome);
await context.Response.WriteAsync(welcome);
});
}

DefaultFiles内部会自动将 / 修改为 index.html 然后交给其他中间件处理,所以需要放在 StaticFiles 的前面。

使用 FileServer 也可以实现同样的效果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public void Configure(
IApplicationBuilder app,
IHostingEnvironment env,
IConfiguration config,
ILogger<Startup> logger,
IWelcomeService welcomeService)
{
if (env.IsDevelopment())
{
app.UseDeveloperExceptionPage();
}

app.UseFileServer();

//var welcome = config["Welcome"];

app.Run(async (context) =>
{
var welcome = welcomeService.getMessage();
logger.LogInformation(welcome);
await context.Response.WriteAsync(welcome);
});
}

中间件的一般注册方式

除了使用内置的中间件之外,还可以用以下几种方式注册中间件:

  • Use
  • UseWhen
  • Map
  • MapWhen
  • Run

UseUseWhen 注册的中间件在执行完毕之后可以回到原来的管道上;
MapMapWhen 可以在新的管道分支上注册中间件,不能回到原来的管道上;
When的方法可以通过 context 做更多的中间件执行的条件;
Run用法和 Use 差不多,只不过不需要接收 next 参数,放在管道尾部;

例如实现返回对应路径内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
/// <summary>
/// 注册应用程序所需的服务
/// </summary>
public void ConfigureServices(IServiceCollection service)
{

}

/// <summary>
/// 注册管道中间件
/// </summary>
public void Configure(IApplicationBuilder app, IHostEnvironment env)
{
// 开发环境,添加开发者异常页面
if(env.IsDevelopment())
{
app.UseDeveloperExceptionPage();
}

// Use 方式
app.Use(async (context, next) =>
{
if(context.Request.Path == new PathString("/use"))
{
await context.Response.WriteAsync($"Path: {context.Request.Path}");
}
await next();
});

// UseWhen 方式
app.UseWhen(context => context.Request.Path == new PathString("/usewhen"),
a => a.Use(async (context, next) =>
{
await context.Response.WriteAsync($"Path: {context.Request.Path}");
await next();
}));

// Map 方式
app.Map(new PathString("/map"),
a => a.Use(async (context, next) =>
{
// context.request.path 获取不到正确的路径
//await context.Response.WriteAsync($"Path: {context.Request.Path}");
await context.Response.WriteAsync($"PathBase: {context.Request.PathBase}");
foreach(var item in context.Request.Headers)
{
await context.Response.WriteAsync($"\n{item.Key}: {item.Value}");
}
}));

// MapWhen 方式
app.MapWhen(context => context.Request.Path == new PathString("/mapwhen"),
a => a.Use(async (context, next) =>
{
await context.Response.WriteAsync($"Path: {context.Request.Path}");
await next();
}));

// Run 放在最后,可有可无,主要为了验证是否可以回到原来的管道上继续执行
app.Run(async (context)=>
{
await context.Response.WriteAsync("\nCongratulation, return to the original pipe.");
});
}

可以看到只有 /use/usewhen可以执行到Run

注:这里碰到一个问题,就是访问 /map 路径的时候获取到的 context.Request.Path 为空,其他字段获取都挺正常,神奇。不过,可以使用 context.Request.PathBase 获取到。

自己封装中间件

对于上面注册中间件的几种方式,比如 Use 内部如果写太多的代码也不合适,所以可以自己封装中间件,封装完成之后可以像内置中间件一样使用 UseXxxx 的方式注册。

本例目标要完成一个中间件可以检测 HTTP 请求方法,仅接受 GETHEAD 方法,步骤如下:
新建一个文件夹mymiddleware,新建文件HttpMethodCheckMiddleware.cs,中间件封装需要实现两个方法:

  • HttpMethodCheckMiddleware: 构造函数,参数类型为RequestDelegate
  • Invoke: 中间件调度函数,参数类型为HttpContext,返回类型为Task

文件内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;

namespace middleware.mymiddleware
{
/// <summary>
/// 请求方法检查中间件,仅处理 HEAD 和 GET 方法
/// </summary>
public class HttpMethodCheckMiddleware
{
private readonly RequestDelegate _next;

/// <summary>
/// 构造方法,必须有的
/// </summary>
/// <param name="requestDelegate"> 下一个中间件</param>
public HttpMethodCheckMiddleware(RequestDelegate requestDelegate)
{
this._next = requestDelegate;
}

/// <summary>
/// 中间件调度方法
/// </summary>
/// <param name="context">HTTP 上下文</param>
/// <returns>TASK 任务状态</returns>
public Task Invoke(HttpContext context)
{
// 如果符合条件,则将 httpcontext 传给下一个中间件处理
if(context.Request.Method.ToUpper().Equals(HttpMethods.Head)
|| context.Request.Method.ToUpper().Equals(HttpMethods.Get))
{
return _next(context);
}

// 否则直接返回处理完成
context.Response.StatusCode = 400;
context.Response.Headers.Add("X-AllowedHTTPVerb", new[] {"GET,HEAD"});
context.Response.ContentType = "text/plain;charset=utf-8"; // 防止中文乱码
context.Response.WriteAsync("只支持 GET、HEAD 方法");
return Task.CompletedTask;
}
}
}

这样就可以直接在 Startup 中使用了:

1
app.UseMiddleware<HttpMethodCheckMiddleware>();

还可以编写一个扩展类,封装成类似内置中间件的方式 UseXxx。新建CustomMiddlewareExtension.cs 文件,内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
using Microsoft.AspNetCore.Builder;

namespace middleware.mymiddleware
{
/// <summary>
/// 封装中间件的扩展类
/// </summary>
public static class CustomMiddlewareExtension
{
/// <summary>
/// 添加 HttpMethodCheckMiddleware 中间件的扩展方法
/// </summary>
public static IApplicationBuilder UseHttpMethodCheckMiddleware(this IApplicationBuilder app)
{
return app.UseMiddleware<HttpMethodCheckMiddleware>();
}
}
}

现在就可以直接调用 UseHttpMethodCheckMiddleware 注册中间件了.

执行结果截图省略。

疑问 :那个CustomMiddlewareExtension 也没见引用,怎么就可以直接使用 app.UseHttpMethodCheckMiddleware 方法了?
有的可能和我一样,c#都没有学明白就直接开始撸 dotnet 了,看到这一脸懵逼,不过经过一番搜索,原来这是 c# 中对已有类或接口进行方法扩展的一种方式,参考C# 编程指南

内置路由

这一节先当了解,暂时用处不大,学完也会忘掉

先简单看一下 ASP.NET core 内置的路由方式(直接上 startup.cs 代码内容):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;

namespace routing
{
public class Startup
{
public void ConfigureServices(IServiceCollection servcies)
{

}

public void Configure(IApplicationBuilder app)
{
// 新建一个路由处理器
var trackPackageRouteHandler = new RouteHandler(context =>
{
var routeValues = context.GetRouteData().Values;
return context.Response.WriteAsync($"Hello! Route values: {string.Join(", ", routeValues)}");
});
var routeBuilder = new RouteBuilder(app, trackPackageRouteHandler);
// 通过 MapRoute 添加路由模板
routeBuilder.MapRoute("Track Package Route", "package/{opration}/{id:int}");
routeBuilder.MapGet("hello/{name}", context =>
{
var name = context.GetRouteValue("name");
return context.Response.WriteAsync($"Hi, {name}!");
});
var routes = routeBuilder.Build();
app.UseRouter(routes);
}
}
}

从代码中可知,需要先创建一个路由处理器 trackPackageRouteHandler,然后通过RouteBuilderapptrackPackageRouteHandler 绑定,而且需要添加一个匹配模板,最后将生成的路由器添加到 app 中。
其中添加路由匹配模板是使用了不同的方法:

  • MapRoute: 这个方法设定一个路由模板,匹配成功的请求会路由到trackPackageRouteHandler;
  • MapGet: 这个方法添加的模板,只适用于 GET 请求方式,并且第二个参数可以指定处理请求的逻辑;

上面设置路由的方式过于复杂,所以一般情况下通常使用 MVC 将对应的 URL 请求路由到 Controller 中处理,简化路由规则。

Controller 和 Action

在开始 MVC 路由之前,先来学习一下 ControllerAction他们的关系以及如何创建。

Controller一般是一些 public 类,Action对应 Controller 中的 public 函数,所以他们的关系也很明了:一个 Controller 可以有多个Action

Controller如何创建,默认情况下满足下面的条件就可以作为一个Controller

  • 在项目根目录的 Controllers
  • 类名称以 Controller 结尾并继承自 Controller,或被[Controller] 标记的类
  • 共有类
  • 没有被 [NotController] 被标记

例如一个 Contoller 的常用模式如下:

1
2
3
4
5
using Microsoft.AspNetCore.Mvc;
public class HomeController : Controller
{
//...
}

Action 就不需要许多条条框框了,只要写在 Controller 中的方法函数都会被当成 Action 对待,如果不想一个函数被当做 Action 则需要添加 [NotAction] 标记。

留待测试:

  1. 如果同时添加 [Controller][NotController]会发生什么状况?是谁在最后谁生效吗还是报错?
  2. 是不是只需要满足 Controller 后缀就可以了,不一定非得继承Controller,继承他只是为了使用一些已经打包好的父类函数。

MVC 路由

首先创建一个 HomeController 测试路由用,需要创建到 Controllers 目录下:

1
2
3
4
5
6
7
8
9
10
11
12
using Microsoft.AspNetCore.Mvc;

namespace routing.Controllers
{
public class HomeController: Controller
{
public string Index()
{
return "Hello from HomeController.Index";
}
}
}

.net core 2.0.net core 3.0 创建路由的方式有所不同,现在分开说一下,先说一下旧的方式。

先在 ConfigureServices 中注册 MVC 服务,然后 Configure 中配置路由模板:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public void ConfigureServices(IServiceCollection service)
{
// 注册服务
service.AddMvc();
}

public void Configure(IApplicationBuilder app, IHostEnvironment env)
{
if(env.IsDevelopment())
{
app.UseDeveloperExceptionPage();
}
// 路由模板
app.UseMvc(routes =>
{
routes.MapRoute(template: "{controller}/{action}/{id?}",
defaults: new {controller = "Home", action = "Index"});
});

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

但是放到 dotnet3 里面是会报错的:

1
MVCRouteStartup.cs(23,13): warning MVC1005: Using 'UseMvc' to configure MVC is not supported while using Endpoint Routing. To continue using 'UseMvc', please set 'MvcOptions.EnableEndpointRouting = false' inside 'ConfigureServices'.

提示 UseMvc 不支持 Endpoint Routing,通过查资料(stackoverflow)找到原因,说的很清楚:2 的时候MVC 路由基于 IRoute,3 改成Endpoint 了,官方推荐将 UseMVC 使用 UseEndpoiont 替换:

1
2
3
4
5
6
7
app.UseRouting(); // 必须写,如果使用了 UseStaticFiles 要放在他之前
app.UseEndpoints(endpoionts =>
{
endpoionts.MapControllerRoute(name: "MVC TEST ROUTE",
pattern: "{controller}/{action}/{id?}",
defaults: new {controller = "Home", action = "Index"});
});

ConfigureServices中注册 MVC 也有两种方式:

1
services.AddMVC();

1
2
service.AddControllersWithViews();
service.AddRazorPages();

当然,如果不想把 UseMap 去掉,那么可以按照报错的提示在 AddMVC 的时候配置一下参数禁用EndpointRoute

1
services.AddMvc(options => options.EnableEndpointRouting = false);

然后就可以跑起来了:

好,扯了半天报错,还是回到 mvc 路由上,上面是简单演示了一下在 Startup 中如何创建路由,其实 mvc 路由有两种定义方式:

  • 约定路由 :上面使用的方式就是约定路由,需要在Startup 中配置;
  • 特性路由 :使用[Route] 直接对 controlleraction进行标记;

修改 HomeController 加上路由标记:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
using Microsoft.AspNetCore.Mvc;

namespace routing.Controllers
{
[Route("h")]
[Route("[controller]")]
public class HomeController: Controller
{
[Route("")]
[Route("[action]")]
public string Index()
{
return "Hello from HomeController.Index";
}
}
}

通过 [controller][action]就可以动态的指代 homeindex(路径不区分大小写),这样如果路由会随着类名或方法名称的改变自动调整。

并且可以看出,可以多个 [Route] 标记重叠使用,例如访问 /h/home/index效果一样:

通过实验可以看出,特性路由会覆盖掉约定路由

先总结这些吧,突然发现 asp.net core 这个东西还是挺先进的,比如依赖注入,Startup中的函数多数都是interface,为什么直接对接口操作就可以改变一些东西或者让我们可以自己注册一个中间件到 app 上,然后为什么都不需要引用或者实例化就可以直接用 app 调用了,这都和依赖注入有关系吧,还有接口的设计理念也好像和其他语言的不太一样,神奇了。

实验代码

放到了 github 上,部分代码好像丢失了,不过应该不要紧。




root@kali ~# cat 重要声明
本博客所有原创文章,作者皆保留权利。转载必须包含本声明,保持文本完整,并以超链接形式注明出处【[Techliu](https://scriptboy.cn)】。查看和编写文章评论都需翻墙,为了更方便地获取文章信息,可订阅[RSS](https://feeds2.feedburner.com/techliu),如果您还没有一款喜爱的阅读器,不妨试试[Inoreader.](https://www.inoreader.com)。
root@kali ~# Thankyou!

⬆︎TOP