LiteApi 内部原理(或如何构建您自己的 WebAPI 中间件)





5.00/5 (3投票s)
本文涵盖了 LiteApi 的内部工作原理,并解释了在 ASP.NET Core 上运行的类似 MVC 的 WebAPI 中间件的创建方面。
引言
LiteApi 是一个开源的 ASP.NET Core 中间件,用于创建(RESTful)HTTP Web 服务。目前它仅支持 JSON,并计划在 XML 和任何其他内容类型方面进行扩展。本文将向您介绍 LiteApi 工作原理的高层思想以及一些底层实现细节。如果您对 LiteApi 文档感兴趣,可以在此处找到。
背景
ASP.NET Core 应用程序围绕中间件构建。我第一次遇到以这种方式构建 Web 应用程序的想法是在我接触 Node.js Express 堆栈时。后来我遇到了OWIN 和 Katana。通过 OWIN 和 Katana,Microsoft 创建了一种使用中间件管道构建 Web 应用程序的方式。OWIN 和 Katana 为 ASP.NET Core 架构铺平了道路。
在 ASP.NET Core 上编写 Web 应用程序时,中间件会堆叠在一起。它们用于分离应用程序的逻辑部分。一个中间件可以用于读取 Cookie 并将身份验证数据注入 HTTP 请求对象,另一个中间件可以用于记录异常,还有另一个中间件可以用于测量应用程序的性能,等等…… MVC 6(或 MVC Core)本身是作为中间件构建的,并且在 ASP.NET Core 管道中运行。
这种新架构为 .NET Web 开发人员开辟了新的视野。我们现在比以往任何时候都更能创建可无缝集成到 Web 应用程序中的可重用代码块。这些块在 HTTP 请求/响应级别上工作,它们被称为中间件。如果您想了解更多关于中间件的信息,您应该查看文档页面。
LiteApi 是一个中间件。它是一个能够识别控制器和操作的中间件。通过遵循约定和配置,LiteApi 可以通过调用操作并返回该操作的响应来响应 HTTP 请求。LiteApi 使用 JSON,不支持 Razor 或任何其他视图引擎。尽管可以扩展 LiteApi 并使用 LiteApi 构建 HTML 响应,但其目的仅是作为 Web API 中间件。
创建 LiteApi 应用程序
在我们深入了解 LiteApi 的内部工作原理之前,我们应该先快速了解一下如何用它来构建应用程序。(接下来的示例代码取自入门文章。)编写简单 LiteApi 应用程序的步骤是:
- 从空模板创建 ASP.NET Core Web 应用程序并添加 nuget 包
- 在
Startup.cs
中注册 LiteApi - 编写控制器
1. 首先,创建一个空的 ASP.NET Core Web 应用程序,然后通过在程序包管理器控制台中运行以下命令安装 .nupkg 来将 LiteApi 添加到您的应用程序中。
PM> Install-Package LiteApi -Pre
2. 安装程序包后,在 Startup.cs
文件中注册中间件。(此时 Visual Studio 可能会出现异常,并报告 UseLiteApi
是一个未识别的方法,只需重新启动 IDE,希望很快就能修复。)这是 Startup.cs
中的 Configure
方法。
public void Configure(IApplicationBuilder app, IHostingEnvironment env, ILoggerFactory loggerFactory)
{
loggerFactory.AddConsole();
if (env.IsDevelopment())
{
app.UseDeveloperExceptionPage();
}
app.UseLiteApi(); // <-- key line
app.Run(async (context) =>
{
await context.Response.WriteAsync("Hello World!");
});
}
3. 现在我们准备编写一些控制器。我们要编写的第一个控制器是简单的 MathController
。为了提高可读性,省略了 using 语句和命名空间,并且所有方法都写成了表达式体方法(尽管它们也可以是经典的、老式的)。我们使用的关键命名空间是 LiteApi
和 LiteApi.Attributes
。
public class MathController: LiteController
{
public int Add(int a, int b) => a + b;
[ActionRoute("/{a}/minus/{b}")]
public int Minus(int a, int b) => a - b;
public int Sum(int[] ints) => ints.Sum();
}
我们的 MathController
是一个 C# 类,可以位于任何文件夹中,按惯例它通常位于 API
或 Controllers
文件夹中。控制器必须继承 LiteController
基类,并且每个操作都必须是公共方法。按惯例,所有公共方法都是 HTTP GET
操作。让我们分别看一下每个操作。
Add
操作将响应/api/math/add?a=5&b=4
URL。这意味着默认情况下,操作名称包含在 URL 中,如何在下一个示例控制器中省略操作名称并使我们的 URL 更具 RESTful 风格将会变得清晰。Minus
操作将响应/api/math/5/minus/4
URL。这种行为是通过ActionRouteAttribute
实现的。此属性对于成功形成漂亮的 RESTful URL(例如/api/books/{id}
)至关重要。Sum
操作接受整数数组,在这种情况下 URL 将是/api/math/sum?ints=5&ints=4&ints=7
。LiteApi 可以接受集合(IEnumerable<T>
、T[]
、List<T>
)以及字典(Dictionary<TKey, TValue>
和IDictionary<TKey, TValue>
)。有关如何传递字典的详细信息超出了本文的范围。如果您想了解更多关于传递字典的信息,请查阅文档。
基本知识现在应该清楚了。是时候创建一个 RESTful 控制器了。
// Restful will tell LiteApi to ignore action names when matching URL to action,
// ControllerRoute will tell LiteApi to use specified path to target the controller
[Restful, ControllerRoute("/api/v2/actors")]
public class ActorsController : LiteController, IActorsService
{
private readonly IActorsService _service;
public ActorsController(IActorsService service)
{
_service = service;
}
[HttpPost] // will respond to POST /api/v2/actors
public ActorModel Add(ActorModel model) => _service.Add(model);
[HttpDelete, ActionRoute("/{id}")] // will respond to DELETE /api/v2/actors/{id}
public bool Delete(Guid id) => _service.Delete(id);
[HttpGet, ActionRoute("/{id}")] // HttpGet is optional, will respond to GET /api/v2/actors/{id}
public ActorModel Get(Guid id) => _service.Get(id);
[HttpGet] // HttpGet is optional, will respond to GET /api/v2/actors
public IEnumerable<ActorModel> GetAll() => _service.GetAll();
[HttpPut, ActionRoute("/{id}")] // will respond to PUT /api/v2/actors/{id}
public ActorModel Update(Guid id, ActorModel model) => _service.Update(id, model);
}
ActorsController
依赖于 IActorsService
并使用 ActorModel
来传递一些数据。这些类的详细信息对本文并不重要,相反,我们将专注于控制器。通过查看上面的代码,我们可以得出以下几点:
- 构造函数与内置的 DI 系统一起工作。我们可以请求在 DI 容器中注册的任何服务。
Restful
属性将告知中间件在与 URL 匹配时排除操作名称。ControllerRoute
属性将设置控制器应响应的基础 URL。HttpGet
、HttpPost
、HttpPut
和HttpDelete
是用于告知中间件操作应响应哪些 HTTP 方法的属性。HttpGet
是可选的。默认情况下,当没有设置Http...Attribute
时,公共方法被视为HTTP GET
操作。
现在我们已经了解了 LiteApi 的一些基本功能,是时候看看它是如何工作的了。
LiteApi 内部机制
该中间件的内部工作可以分为两个方面。第一个方面是初始化,第二个方面是运行时处理 HTTP 请求。初始化在第一次运行时完成,负责查找和验证控制器、操作和操作参数。中间件的第二个方面负责在收到 HTTP 请求时查找匹配的控制器和操作。如果找到匹配的控制器/操作,LiteApi 将解释参数、调用操作并返回响应。如果没有找到匹配的控制器/操作,LiteApi 将调用下一个中间件(如果 LiteApi 之后注册了的话)。
请注意,LiteApi 由大约 50 个文件组成(主要是接口和类,不包括测试类和示例项目),本文无法涵盖所有内容,因此您会经常看到呈现的接口和特定方法。接下来的内容将尝试从高层次的角度解释中间件的工作原理,只提供一些实现细节。如果您对所有细节都感兴趣,可以深入研究代码库。
中间件的初始化
LiteApi 初始化期间执行以下步骤:
- 注册控制器
- 查找应用程序程序集并找到控制器
- 为每个控制器查找操作
- 为每个操作查找输入参数和结果响应类型
- 执行验证
- 验证所有控制器
- 为每个控制器验证操作
- 为每个操作验证参数
控制器、操作和操作参数的注册
对于控制器、操作和参数的注册,我们有以下接口:
public interface IControllerDiscoverer
{
ControllerContext[] GetControllers(Assembly assembly);
}
public interface IActionDiscoverer
{
ActionContext[] GetActions(ControllerContext controllerCtx);
}
public interface IParametersDiscoverer
{
ActionParameter[] GetParameters(ActionContext actionCtx);
}
ControllerContext
、ActionContext
和 ActionParameter
是保存控制器元数据的类。接口应该是不言自明的。IControllerDiscoverer
的默认实现将检查提供的程序集并查找控制器类。这里是默认控制器发现器实现中的一些有趣代码:
public ControllerContext[] GetControllers(Assembly assembly)
{
var types = assembly.GetTypes()
.Where(x => typeof(LiteController).IsAssignableFrom(x) && !x.GetTypeInfo().IsAbstract)
.ToArray();
ControllerContext[] ctrls = new ControllerContext[types.Length];
for (int i = 0; i < ctrls.Length; i++)
{
ctrls[i] = new ControllerContext
{
ControllerType = types[i],
RouteAndName = GetControllerRute(types[i]),
IsRestful = types[i].GetTypeInfo().GetCustomAttributes<RestfulAttribute>().Any()
};
ctrls[i].Actions = _actionDiscoverer.GetActions(ctrls[i]);
ctrls[i].Init();
}
return ctrls;
}
正如我们所见,使用反射来检索程序集中的所有类型并查找控制器。为了区分控制器和其他类,GetControllers
方法会检查类是否继承自 LiteController
类,并确保找到的控制器不是抽象控制器。找到控制器后,我们为每个控制器调用操作发现器方法来检索操作。操作发现器中的相关方法如下所示:
public ActionContext[] GetActions(ControllerContext controllerCtx)
{
var properties = controllerCtx.ControllerType
.GetProperties(BindingFlags.Instance | BindingFlags.Public);
var propertyMethods = new List<string>();
propertyMethods.AddRange(properties.Where(x => x.GetMethod?.Name != null)
.Select(x => x.GetMethod.Name));
propertyMethods.AddRange(properties.Where(x => x.SetMethod?.Name != null)
.Select(x => x.SetMethod.Name));
return controllerCtx.ControllerType
.GetMethods(BindingFlags.Instance | BindingFlags.Public)
.Where(x => !propertyMethods.Contains(x.Name))
.Where(MethodIsAction)
.Select(x => GetActionContext(x, controllerCtx))
.ToArray();
}
private ActionContext GetActionContext(MethodInfo method, ControllerContext ctrlCtx)
{
string methodName = method.Name.ToLowerInvariant();
var segmentsAttr = method.GetCustomAttribute<ActionRouteAttribute>();
RouteSegment[] segments = new RouteSegment[0] ;
if (!ctrlCtx.IsRestful)
{
segments = new [] { new RouteSegment(methodName) };
}
if (segmentsAttr != null)
{
segments = segmentsAttr.RouteSegments.ToArray();
}
var actionCtx = new ActionContextan
{
HttpMethod = GetHttpAttribute(method).Method,
Method = method,
ParentController = ctrlCtx,
RouteSegments = segments
};
actionCtx.Parameters = _parameterDiscoverer.GetParameters(actionCtx);
return actionCtx;
}
在提供的代码中,可以看出 LiteApi 首先查找属性。查找属性的原因是在 .NET Core(和 NET Standard)中,属性会被编译成方法,所以我们必须过滤掉它们。对于过滤后的方法,LiteApi 应用另一个名为 MethodIsAction
的过滤器。此过滤器检查是否存在 DontMapToApiAttribute
,该属性可用于阻止将方法注册为操作。在正确的方法被过滤后,LiteApi 为每个方法创建一个 ActionContext
。ActionContext
获取方法名称,该名称可以设置为路由段。如果父控制器是 RESTful 的,则路由段为空,除非存在 ActionRouteAttribute
。路由段用于将请求路由匹配到控制器和操作,有关路由段匹配的更多信息将在本文后面进行描述。对于每个操作上下文,GetActionContext
方法会调用 IParametersDiscoverer.GetParameters
。
LiteApi 操作中的参数可以通过以下方式接收:
- 查询(简单类型的默认参数源)
- 路由段(在
ActionRouteAttribute
中设置) - 正文(对于不被视为简单类型的类型,其默认参数源)
- 头部(可以通过 FromHeaderAttribute 设置)
- 依赖注入容器(可以通过 FromServicesAttribute 设置)
发现和解析参数可能是中间件执行的最具挑战性的工作。参数可以来自不同的来源,并且不同来源的参数有不同的规则。例如,可空参数不允许出现在路由段中,正文参数不允许出现在 GET
和 DELETE
请求中,集合和字典不允许出现在头部中……除了这些规则之外,更复杂的规则是确定操作重载的规则。例如,我们来看以下三个操作:
public Book Get(string id) => _bookService.Get(id); public Book Get(Guid id) => _bookService.Get(id); public Book Get(int id) => _bookService.Get(id);
所有三个操作都响应相同的 URL,那么 LiteApi 如何知道调用哪个操作呢?经验法则是尝试将参数 id
解析为 Guid
和 int
。如果参数成功解析为 Guid
,它将调用期望 Guid
的操作;如果参数成功解析为整数,它将调用期望 int
的操作;如果解析不成功,它将最终调用期望 string
的操作。更复杂的情况是操作被重载为 int
、int?
、int?[]
和 int[]
。在这种情况下,LiteApi 将始终调用 int?[]
,因为这种类型的参数是其他三种参数类型的超集。在重载 int
、int?
、int[]
时,LiteApi 将调用 int?
操作,因为在解析 int?
时失败的可能性最小,即使有多个值,也只会解析一个。在重载操作时,最好为参数命名,这样 LiteApi 将确切知道要调用哪个操作,而无需解析所有可能的参数类型。
ParameterDiscoverer.GetParameters
方法如下:
public ActionParameter[] GetParameters(ActionContext actionCtx)
{
var methodParams = actionCtx.Method.GetParameters();
ActionParameter[] parameters = new ActionParameter[methodParams.Length];
for (int i = 0; i < methodParams.Length; i++)
{
var param = actionCtx.Method.GetParameters()[i];
bool isFromQuery = false;
bool isFromBody = false;
bool isFromRoute = false;
bool isFromService = false;
bool isFromHeader = false;
string overridenName = null;
if (param.GetCustomAttribute<FromServicesAttribute>() != null)
{
isFromService = true;
}
else
{
isFromQuery = param.GetCustomAttribute<FromQueryAttribute>() != null;
isFromBody = param.GetCustomAttribute<FromBodyAttribute>() != null;
isFromRoute = param.GetCustomAttribute<FromRouteAttribute>() != null;
var headerAttrib = param.GetCustomAttribute<FromHeaderAttribute>();
if (headerAttrib != null)
{
isFromHeader = true;
overridenName = headerAttrib.HeaderName;
}
}
ParameterSources source = ParameterSources.Unknown;
if (isFromService) source = ParameterSources.Service;
else if (isFromHeader) source = ParameterSources.Header;
else if (isFromQuery && !isFromBody && !isFromRoute) source = ParameterSources.Query;
else if (!isFromQuery && isFromBody && !isFromRoute) source = ParameterSources.Body;
else if (!isFromQuery && !isFromBody && isFromRoute) source = ParameterSources.RouteSegment;
parameters[i] = new ActionParameter(
actionCtx,
new ModelBinders.ModelBinderCollection(new JsonSerializer(), _services))
{
Name = param.Name.ToLower(),
DefaultValue = param.DefaultValue,
HasDefaultValue = param.HasDefaultValue,
Type = param.ParameterType,
ParameterSource = source,
OverridenName = overridenName
};
if (parameters[i].ParameterSource == ParameterSources.Unknown)
{
if (parameters[i].IsComplex)
{
parameters[i].ParameterSource = ParameterSources.Body;
}
else if (actionCtx.RouteSegments
.Any(x => x.IsParameter && x.ParameterName == parameters[i].Name))
{
parameters[i].ParameterSource = ParameterSources.RouteSegment;
}
else
{
parameters[i].ParameterSource = ParameterSources.Query;
}
}
}
return parameters;
}
GetParameters
方法有点复杂。复杂的原因是参数可以从不同的来源检索。参数的来源可以显式地用属性设置,或者 LiteApi 可以为您确定从哪里读取参数。例如,如果参数类型为 string
,则该参数的默认来源是查询;但是,如果 ActionRouteAttribute
中有一个同名的参数,那么该参数预计来自路由段。再举一个字符串的例子,如果有一个参数显式设置了 FromBodyAttribute
,那么该参数应该从请求正文接收。以下参数源相关的属性可用:
FromQueryAttribute
FromRouteAttribute
FromBodyAttribute
FromHeaderAttribute
FromServicesAttribute
这里是一个从头部接收参数的控制器示例:
public class HeaderParametersController: LiteController
{
// parameter values will be retrieved from headers "i" and "x-overriden-param-name-j"
public int Add([FromHeader]int i, [FromHeader("x-overriden-param-name-j")]int j) => i + j;
}
参数的来源在 ActionParameter
对象的 ParameterSource
(枚举)属性中设置。为了安全起见,ParameterSource
的默认值是 Unknown
。GetParameters 方法会将 Unknown
值更改为参数的实际值。如果未发现实际的参数来源值,则在验证期间(在 Web 应用程序首次运行时)将抛出异常。
注册的控制器、操作和操作参数的验证
LiteApi 使用以下接口来验证控制器/操作/参数:
public interface IControllersValidator
{
IEnumerable<string> GetValidationErrors(ControllerContext[] controllerCtxs);
}
public interface IActionsValidator
{
IEnumerable<string> GetValidationErrors(ActionContext[] actionCtxs, bool isControllerRestful);
}
public interface IParametersValidator
{
IEnumerable<string> GetParametersErrors(ActionContext actionCtx);
}
这些接口的实现将检查是否有具有相同基础 URL 的控制器,所有参数规则是否都已满足,授权规则是否有效等等……我不会详细介绍验证,我认为它是中间件的非关键部分,但是,如果您感兴趣,可以在此处找到源代码,并且可以在此处找到预期错误的列表。
响应 HTTP 请求
初始化(注册和验证)完成后,LiteApi 即可响应 HTTP 请求。中间件在收到请求时执行的步骤如下:
- 为请求创建日志记录器(每个请求一个日志记录器)
- 查找要调用的操作
- 如果找到操作
- 检查是否需要 HTTPS(如果需要且请求不是 HTTPS,则返回 400)
- 调用操作
- 如果没有找到操作,则查找下一个中间件并调用它(如果存在下一个中间件)
- 如果找到操作
调用操作不是一个步骤,它包括运行过滤器(例如,授权)、创建控制器实例、解析参数和调用操作。下图描述了响应 HTTP 请求时所采取步骤的高层概述。
这是位于中间件类中的接收 HTTP 请求的代码(为了清晰和可读性,代码已剥离执行日志记录操作的行):
public async Task Invoke(HttpContext context)
{
ActionContext action = _pathResolver.ResolveAction(context.Request, log);
if (action == null)
{
if (_next != null)
{
await _next?.Invoke(context);
}
}
else
{
if (Options.RequiresHttps && !context.Request.IsHttps)
{
context.Response.StatusCode = 400;
await context.Response.WriteAsync("Bad request, HTTPS request was expected.");
}
else
{
await _actionInvoker.Invoke(context, action, log);
}
}
}
Invoke
方法由 ASP.NET Core 本身调用。当收到 HTTP 请求时,它是中间件的入口点。一些被调用的方法需要 ILogger
作为参数。创建 ILogger
的代码为了可读性已被删除。HttpContext
参数由 ASP.NET Core 提供,该参数被所有中间件用于检查特定中间件是否应响应请求,并在有任何响应由特定中间件写入时写入响应。
查找合适的操作
为了查找正确的操作,使用了 IPathResolver
的实现(名称非常糟糕,应该更改)。同样,日志代码行已被删除。这是来自 IPathResolver
默认实现的的代码:
public ActionContext ResolveAction(HttpRequest request, ILogger logger = null)
{
ActionContext[] actions = GetActionsForPathAndMethod(request).ToArray();
if (actions.Length == 1)
{
return actions[0];
}
if (actions.Length == 0) return null;
return ResolveActionContextByQueryParameterTypes(request, actions, logger);
}
private IEnumerable<ActionContext> GetActionsForPathAndMethod(HttpRequest request)
{
string path = request.Path.Value.ToLower();
string[] segments = path.TrimStart('/').TrimEnd('/').Split(_separator, StringSplitOptions.None);
var method = (SupportedHttpMethods)Enum.Parse(typeof(SupportedHttpMethods), request.Method, true);
foreach (var ctrl in _controllerContrxts)
{
var actions = ctrl.GetActionsBySegments(segments, method).ToArray();
foreach (var a in actions)
{
yield return a;
}
}
}
private ActionContext ResolveActionContextByQueryParameterTypes(
HttpRequest request,
ActionContext[] actions,
ILogger logger)
{
// performs parameter parsing in order to find appropriate action
// parameter parsing will be described later on in the article
}
LiteApi 遍历所有控制器并调用 GetActionsBySegments
。如果只找到一个操作,它将被返回调用;如果找到多个操作,则执行参数解析以找到合适重载的操作。
GetActionsBySegments
检查 URL 和 HTTP 方法,并返回与 URL 和方法匹配的操作。URL 通过分割成段并查找哪些操作段与请求路由段匹配来检查。使用段是因为它们可以包含参数,因此它们不总是常量。这里是 GetActionsBySegments
方法背后的代码:
public IEnumerable<ActionContext> GetActionsBySegments(
string[] requestSegments,
SupportedHttpMethods method)
{
if (RequestSegmentsMatchController(requestSegments))
{
string[] requestSegmentsWithoutController =
requestSegments.Skip(RouteSegments.Length).ToArray();
var actions = Actions.Where(x =>
x.RouteSegments.Length == requestSegmentsWithoutController.Length
&& x.HttpMethod == method)
.ToArray();
if (actions.Length > 0)
{
if (IsActionMatchedToRequestSegments(action, requestSegmentsWithoutController))
{
yield return action;
}
}
}
}
private bool RequestSegmentsMatchController(string[] requestSegments)
{
if (requestSegments.Length < RouteSegments.Length) return false;
for (int i = 0; i < RouteSegments.Length; i++)
{
if (RouteSegments[i] != requestSegments[i]) return false;
}
return true;
}
private bool IsActionMatchedToRequestSegments(ActionContext action, string[] requestSegments)
{
for (int i = 0; i < action.RouteSegments.Length; i++)
{
if (action.RouteSegments[i].IsConstant)
{
if (action.RouteSegments[i].ConstantValue != requestSegments[i]) return false;
}
}
return true;
}
GetActionsBySegments
是 ControllerContext
类中的一个方法。该类用于存储控制器的元数据。在其他重要信息中,用于查找正确控制器/操作的属性是 RouteSegments
。此属性存储控制器路由段,可以与请求路由段进行比较。
首先,LiteApi 检查起始请求路由段是否匹配控制器的路由段。例如,如果控制器路由是 /api/books/
,而请求是 /someFile.js
,那么很明显,控制器路由段的数量多于请求路由段的数量。在这种情况下,LiteApi 将跳过其他控制器匹配检查。如果请求路由段的数量大于或等于控制器路由段的数量,LiteApi 将检查请求路由段的前几段是否与控制器路由段匹配。例如,如果请求是 /api/books/category/dramas
,控制器路由是 /api/books
,LiteApi 将匹配 /api
到 /api
,/books
到 /books
,然后 LiteApi 将尝试匹配控制器的每个操作。
操作匹配是通过 IsActionMatchedToRequestSegments
方法完成的。IsActionMatchedToRequestSegments
方法再次检查路由段,但这里只检查常量路由段,因为操作路由段可以包含操作参数(如我们在 /api/math/5/minus/4
示例中看到的)。
每个 ActionContext
都存储操作的路由段。操作级别的路由段可以是常量或参数。例如,在 /api/books/category/{categoryId}
中,除了 {categoryId}
之外,所有段都是常量。RouteSegment
类非常简单,看起来像这样:
public class RouteSegment
{
public string OriginalValue { get; private set; }
public bool IsConstant { get; private set; }
public bool IsParameter => !IsConstant;
public string ParameterName { get; private set; }
public string ConstantValue { get; private set; }
public RouteSegment(string segment)
{
if (segment == null) throw new ArgumentNullException(nameof(segment));
OriginalValue = segment;
IsConstant = !(OriginalValue.StartsWith("{", StringComparison.Ordinal)
&& OriginalValue.EndsWith("}", StringComparison.Ordinal));
if (!IsConstant)
{
ParameterName = OriginalValue.TrimStart('{').TrimEnd('}').ToLower();
}
else
{
ConstantValue = OriginalValue.ToLower();
}
}
}
调用操作
找到正确操作后,需要调用它。IActionInvoker
负责调用操作。
public interface IActionInvoker
{
Task Invoke(HttpContext httpCtx, ActionContext actionCtx, ILogger logger);
}
操作调用器接受 HttpContext
、ActionContext
和 ILogger
。ActionContext
是匹配请求的操作,ILogger
是上下文感知日志记录器,它会记录请求 ID 以及任何其他信息,因此在调试时我们可以看到并跟踪单个请求的日志。IActionInvoker
的默认实现使用反射。计划(为将来发布)实现一个更快的调用器,该调用器使用编译表达式、委托或 IL 发射。目前,LiteApi 比 MVC 快,一旦实现新的调用器,它应该会更快。
操作调用器返回 Task
,因为操作可以是 async
Task
。
调用操作时执行的步骤如下:
- 运行过滤器
- 如果过滤器失败,则写入带有 4xx 状态码和错误描述的响应
- 构造控制器
- 解析参数(如果有)
- 调用操作并获取结果
- 检查结果是 Task、void 还是其他对象
- 如果结果是 Task,则 await 它
- 如果 await 结果不是 void,则序列化响应
- 否则,如果结果不是 void,则序列化响应
- 如果结果是 Task,则 await 它
- 设置响应代码(2xx)
- 如果存在序列化响应,则设置响应内容类型并将响应写入响应正文
调用操作的方法和类很长,因此我将代码分成几个步骤。如果您对整个操作调用器类感兴趣,请查看 GitHub 上的文件。
1. 运行过滤器步骤负责检查 RequireHttpsAttribute
和授权属性。这些属性被称为过滤器。每个过滤器都实现 IApiFilter
或 IApiFilterAsync
,并且检查的结果是返回 ApiFilterRunResult
。
public interface IApiFilter
{
bool IgnoreSkipFilters { get; }
ApiFilterRunResult ShouldContinue(HttpContext httpCtx);
}
public interface IApiFilterAsync
{
bool IgnoreSkipFilters { get; }
Task<ApiFilterRunResult> ShouldContinueAsync(HttpContext httpCtx);
}
public class ApiFilterRunResult
{
public bool ShouldContinue { get; set; }
public int? SetResponseCode { get; set; }
public string SetResponseMessage { get; set; }
public static ApiFilterRunResult Unauthorized
=> new ApiFilterRunResult
{
ShouldContinue = false,
SetResponseCode = 403,
SetResponseMessage = "User is unauthorized to access the resource"
};
public static ApiFilterRunResult Continue
=> new ApiFilterRunResult
{
ShouldContinue = true
};
public static ApiFilterRunResult Unauthenticated
=> new ApiFilterRunResult
{
ShouldContinue = false,
SetResponseCode = 401,
SetResponseMessage = "User is unauthenticated"
};
}
过滤器作为属性应用于控制器或操作级别。API 过滤器接口具有 IgnoreSkipFilters
属性和 ShouldContinue
(或 ShouldContinueAsync
)方法。IgnoreSkipFilters
用于告诉 LiteApi 即使操作上存在 SkipFiltersAttribute
,也不要跳过此过滤器。当我们需要确保过滤器不被跳过时,此属性很有用。一个永远不应跳过的过滤器的示例是 RequiresHttpsAttribute
。例如,这是一个应用了过滤器的控制器:
[RequiresHttps, RequiresAuthentication]
public class UsersController: LiteController
{
public UserDetails GetCurrentUserDetails() => // ...
[RequiresRoles("Admin")]
public Task<List<User>> GetAllUsers() => // ...
[SkipFilters]
public Task<bool> GetAccessToken(AccessRequest model) => // ...
}
在示例 UsersController
中,我们有三个操作。所有操作都需要 HTTPS 连接,并且需要用户进行身份验证,除了 GetAccessToken
操作。GetAccessToken
操作声明了 SkipFiltersAttribute
,这将使身份验证的要求无效,但不会使 HTTPS 连接的要求无效,因为 RequiresHttpsAttribute
的 IgnoreSkipFilters
属性设置为 true。此外,GetAllUsers
操作有额外的过滤器要求,用户必须是 Admin
角色才能访问该操作。除了 SkipFiltersAttribute
只能应用于方法(操作)之外,所有过滤器属性都可以应用于类(控制器)或方法(操作)。
这是 RequiresHttpsAttribute
过滤器的实现:
[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class)]
public class RequiresHttpsAttribute : Attribute, IApiFilter
{
public bool IgnoreSkipFilters { get; set; } = true;
public ApiFilterRunResult ShouldContinue(HttpContext httpCtx)
{
if (httpCtx.Request.IsHttps) return ApiFilterRunResult.Continue;
return new ApiFilterRunResult
{
SetResponseCode = 400,
SetResponseMessage = "Bad request, HTTPS request was expected.",
ShouldContinue = false
};
}
}
过滤器还可以检查用户是否已通过身份验证,用户是否具有声明、角色、声明值等。作为另一个示例,这里是 RequireRolesAttribute
。
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = true)]
public class RequiresRolesAttribute : RequiresAuthenticationAttribute
{
private readonly string[] _roles;
public RequiresRolesAttribute(params string[] roles)
{
if (roles == null) throw new ArgumentNullException(nameof(roles));
if (roles.Any(string.IsNullOrWhiteSpace) || roles.Length == 0)
throw new ArgumentException("Role cannot be null or empty or white space.");
_roles = roles;
}
public override ApiFilterRunResult ShouldContinue(HttpContext httpCtx)
{
var result = base.ShouldContinue(httpCtx); // check if authenticated
if (!result.ShouldContinue) return result;
bool hasRoles = _roles.All(httpCtx.User.IsInRole);
if (!hasRoles) result = ApiFilterRunResult.Unauthorized;
return result;
}
}
为了检查过滤器,ActionInvoker
类有一个名为 RunFiltersAndCheckIfShouldContinue
的方法。
internal static async Task<ApiFilterRunResult> RunFiltersAndCheckIfShouldContinue(
HttpContext httpCtx,
ActionContext action)
{
if (action.SkipAuth)
{
var nonSkipable = action.ParentController.Filters.Where(x => x.IgnoreSkipFilter);
foreach (var filter in nonSkipable)
{
var shouldContinue = await filter.ShouldContinueAsync(httpCtx);
if (!shouldContinue.ShouldContinue)
{
return shouldContinue;
}
}
return new ApiFilterRunResult { ShouldContinue = true };
}
ApiFilterRunResult result = await action.ParentController.ValidateFilters(httpCtx);
if (!result.ShouldContinue)
{
return result;
}
foreach (var filter in action.Filters)
{
result = await filter.ShouldContinueAsync(httpCtx);
if (!result.ShouldContinue)
{
return result;
}
}
return new ApiFilterRunResult { ShouldContinue = true };
}
首先,该方法检查操作是否具有 SkipFilters
属性。如果具有,则检查非可跳过过滤器并运行它们。如果操作没有跳过过滤器属性,该方法将检查控制器级别的所有过滤器,然后检查操作级别的所有过滤器。未来发布的计划是添加对全局过滤器的支持,因此我们可以声明一个可以应用于中间件级别的过滤器。
2. 构造控制器由 IControllerBuilder
完成。默认实现继承自 ObjectBuilder
,它能够通过从 DI 容器检索构造函数参数来构造任何对象。
internal class ControllerBuilder : ObjectBuilder, IControllerBuilder
{
public ControllerBuilder(IServiceProvider serviceProvider) : base(serviceProvider)
{
}
public LiteController Build(ControllerContext controllerCtx, HttpContext httpContext)
{
var controller = BuildObject(controllerCtx.ControllerType) as LiteController;
controller.HttpContext = httpContext;
return controller;
}
}
LiteController
是所有控制器的基类,因此在 Build
方法中,我们始终可以返回 LiteController
类型的对象。ControllerBuilder
的另一个任务是设置控制器的 HttpContext
属性。BuildObject
方法在继承的 ObjectBuilder
类中定义。
ObjectBuilder
由几个方法组成。这是 BuildObject
方法:
public object BuildObject(Type objectType)
{
ConstructorInfo constructor = GetConstructor(objectType);
ParameterInfo[] parameters = GetConstructorParameters(constructor);
object[] parameterValues = GetConstructorParameterValues(parameters);
object objectInstance = constructor.Invoke(parameterValues);
return objectInstance;
}
BuildObject
使用反射(我正在考虑用编译表达式方法替换反射)。首先调用 GetConstructor
。此方法使用反射获取类的所有构造函数。如果有一个以上的构造函数,则检查是否任何构造函数具有 PrimaryConstructorAttribute
,该属性声明了哪个构造函数应该用于构造对象。(为了清晰起见,执行缓存的代码已被删除。)
private ConstructorInfo GetConstructor(Type objectType)
{
var constructors = objectType.GetConstructors(BindingFlags.Public | BindingFlags.Instance);
if (constructors.Length > 1)
{
constructors = constructors
.Where(x => x.GetCustomAttribute<PrimaryConstructorAttribute>() != null).ToArray();
}
if (constructors.Length != 1)
{
throw new Exception($"Cannot find constructor for {objectType.FullName}. Class has more than one constructor, or "
+ "more than one constructor is using ApiConstructorAttribute. If class has more than one constructor, only "
+ "one should be annotated with ApiConstructorAttribute.");
}
return constructors[0];
}
检索构造函数后,使用 GetConstructorParameters
检索其参数类型。(同样,缓存代码已被删除)。
private ParameterInfo[] GetConstructorParameters(ConstructorInfo constructor)
{
ParameterInfo[] parameters = constructor.GetParameters();
return parameters;
}
剩下的是获取构造函数参数值。这由 GetConstructorParameterValues
完成。
private object[] GetConstructorParameterValues(ParameterInfo[] parameters)
{
object[] values = new object[parameters.Length];
for (int i = 0; i < values.Length; i++)
{
values[i] = _serviceProvider.GetService(parameters[i].ParameterType);
}
return values;
}
3. 解析参数由 IModelBinder
实现完成。IModelBinder
如下所示:
public interface IModelBinder
{
object[] GetParameterValues(HttpRequest request, ActionContext actionCtx);
bool DoesSupportType(Type type, ParameterSources source);
}
IModelBinder
的入口实现是 ModelBinderCollection
,它包含所有实际的 IModelBinder
实现。这是 ModelBinderCollecton
的私有字段和构造函数:
private List<IQueryModelBinder> _queryBinders = new List<IQueryModelBinder>();
private List<IBodyModelBinder> _bodyBinders = new List<IBodyModelBinder>();
private readonly IJsonSerializer _jsonSerializer;
private readonly IServiceProvider _serviceProvider;
public ModelBinderCollection(IJsonSerializer jsonSerializer, IServiceProvider serviceProvider)
{
_jsonSerializer = jsonSerializer ?? throw new ArgumentNullException(nameof(jsonSerializer));
_serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider));
_queryBinders.Add(new BasicQueryModelBinder());
_queryBinders.Add(new CollectionsQueryModelBinder());
_queryBinders.Add(new DictionaryQueryModelBinder());
_bodyBinders.Add(new FormFileBodyBinder());
}
正如我们所见,ModelBinderCollection
包含了所有默认的模型绑定器。此外,它还包含 FormFileBodyBinder
,它可以读取 HTTP 表单发布的(文件)。除了默认模型绑定器之外,该类还可以接受自定义模型绑定器,这些绑定器可以在中间件初始化期间添加。添加自定义绑定器超出了本文的范围,如果您有兴趣,这里有一个示例,演示了如何定义自定义查询模型绑定器。
调用其他模型绑定器的实际方法是 GetParameterValues
。
public object[] GetParameterValues(HttpRequest request, ActionContext actionCtx)
{
object[] values = new object[actionCtx.Parameters.Length];
List<object> args = new List<object>();
foreach (var param in actionCtx.Parameters)
{
if (param.ParameterSource == ParameterSources.Query || param.ParameterSource == ParameterSources.Header)
{
var binder = _queryBinders.FirstOrDefault(x => x.DoesSupportType(param.Type));
if (binder != null)
{
args.Add(binder.ParseParameterValue(request, actionCtx, param));
}
else
{
throw new Exception($"No model binder supports type: {param.Type}");
}
}
else if (param.ParameterSource == ParameterSources.Body)
{
IBodyModelBinder bodyBinder;
if ((bodyBinder = _bodyBinders.FirstOrDefault(x => x.CanHandleType(param.Type))) != null)
{
args.Add(bodyBinder.CreateParameter(request));
continue;
}
using (TextReader reader = new StreamReader(request.Body))
{
string json = reader.ReadToEnd();
args.Add(_jsonSerializer.Deserialize(json, param.Type));
}
request.Body.Dispose();
}
else if (param.ParameterSource == ParameterSources.Service)
{
args.Add(_serviceProvider.GetService(param.Type));
}
else if (param.ParameterSource == ParameterSources.RouteSegment)
{
args.Add(RouteSegmentModelBinder.GetParameterValue(actionCtx, param, request));
}
else
{
throw new ArgumentException(
$"Parameter {param.Name} in controller {actionCtx.ParentController.RouteAndName} in action {actionCtx.Name} "
+ "has unknown source (body or URL). " + AttributeConventions.ErrorResolutionSuggestion);
}
}
return args.ToArray();
}
对于每个参数,该方法检查其来源并为其查找适当的模型绑定器。这里是解析参数的 BasicQueryModelBinder
中的一些示例代码:
public virtual object ParseParameterValue(
HttpRequest request,
ActionContext actionCtx,
ActionParameter parameter)
{
string value = null;
var paramName = parameter.Name;
if (!string.IsNullOrWhiteSpace(parameter.OverridenName)) paramName = parameter.OverridenName;
IEnumerable<KeyValuePair<string, StringValues>> source = null;
if (parameter.ParameterSource == ParameterSources.Query) source = request.Query;
else source = request.Headers;
var keyValue = source.LastOrDefault(x => paramName.Equals(x.Key, StringComparison.OrdinalIgnoreCase));
if (keyValue.Key != null)
{
value = keyValue.Value.LastOrDefault();
}
if (keyValue.Key == null)
{
if (parameter.HasDefaultValue) return parameter.DefaultValue;
string message =
$"Parameter '{parameter.Name}' from {parameter.ParameterSource.ToString().ToLower()} " +
$"(action: '{parameter.ParentActionContext}') does not have default value and " +
$"{parameter.ParameterSource.ToString().ToLower()} does not contain value.";
throw new Exception(message);
}
if (parameter.HasDefaultValue && parameter.Type != typeof(string) && string.IsNullOrEmpty(value)) return parameter.DefaultValue;
return ParseSingleQueryValue(value, parameter.Type, parameter.IsNullable, parameter.Name, new Lazy<string>(() => parameter.ParentActionContext.ToString()));
}
BasicQueryModelBinder
负责解析所有简单类型。ParseParameterValue
方法检查参数源是查询还是头部(它同时支持两者),并检查是否存在要解析的参数的键。如果键不存在,它会检查参数是否有默认值,如果有,则返回默认值。如果键存在,则方法检查值是否存在;如果值不存在,它会尝试返回默认值,最后调用 ParseSingleQueryValue
。
ParseSingleQueryValue
是一个非常简单的方法,我正在寻求提高其性能(如果您有任何想法,请通过评论告诉我)。这是 ParseSingleQueryValue
背后的代码:
public static object ParseSingleQueryValue(
string value,
Type type,
bool isNullable,
string parameterName,
Lazy<string> actionNameRetriever)
{
if (type == typeof(string))
{
return value;
}
if (string.IsNullOrEmpty(value))
{
if (isNullable)
{
return null;
}
throw new ArgumentException($"Value is not provided for parameter: '{parameterName}' in action '{actionNameRetriever.Value}'");
}
// todo: check if using swith with Type.GUID.ToString() would be an option
if (type == typeof(bool)) return bool.Parse(value);
if (type == typeof(char)) return char.Parse(value);
if (type == typeof(Guid)) return Guid.Parse(value);
if (type == typeof(Int16)) return Int16.Parse(value);
if (type == typeof(Int32)) return Int32.Parse(value);
if (type == typeof(Int64)) return Int64.Parse(value);
if (type == typeof(UInt16)) return UInt16.Parse(value);
if (type == typeof(UInt32)) return UInt32.Parse(value);
if (type == typeof(UInt64)) return UInt64.Parse(value);
if (type == typeof(Byte)) return Byte.Parse(value);
if (type == typeof(SByte)) return SByte.Parse(value);
if (type == typeof(decimal)) return decimal.Parse(value);
if (type == typeof(float)) return float.Parse(value);
if (type == typeof(double)) return double.Parse(value);
if (type == typeof(DateTime)) return DateTime.Parse(value);
if (type == typeof(Guid)) return Guid.Parse(value);
throw new ArgumentOutOfRangeException();
}
ParseSingleQueryValue
返回参数类型为 string
的实际值。如果不是,它会检查值是否为 null
或空,如果是且类型是可空的,则返回 null
。如果值存在且参数类型不是 string
,则方法调用匹配类型的解析。
此时大部分工作已完成,我们有了控制器实例、参数值,是时候(4)调用操作,(5)检查结果,写入响应代码和(6)正文了。所有这些步骤都在 ActionInvoker
类中的 Invoke
方法中完成。以下代码是 Invoke
方法中比较有趣的部分。(为了可读性,代码已剥离日志行。)
object result = null;
bool isVoid = true;
if (actionCtx.Method.ReturnType == typeof(void))
{
actionCtx.Method.Invoke(ctrl, paramValues);
}
else if (actionCtx.Method.ReturnType == typeof(Task))
{
var task = (actionCtx.Method.Invoke(ctrl, paramValues) as Task);
await task;
}
else if (actionCtx.Method.ReturnType.IsConstructedGenericType
&& actionCtx.Method.ReturnType.GetGenericTypeDefinition() == typeof(Task<>))
{
isVoid = false;
var task = (dynamic)(actionCtx.Method.Invoke(ctrl, paramValues));
result = await task;
}
else
{
isVoid = false;
result = actionCtx.Method.Invoke(ctrl, paramValues);
}
int statusCode = 405; // method not allowed
switch (httpCtx.Request.Method.ToUpper())
{
case "GET": statusCode = 200; break;
case "POST": statusCode = 201; break;
case "PUT": statusCode = 201; break;
case "DELETE": statusCode = 204; break;
}
httpCtx.Response.StatusCode = statusCode;
httpCtx.Response.Headers.Add("X-Powered-By-Middleware", "LiteApi");
if (!isVoid)
{
if (actionCtx.IsReturningLiteActionResult)
{
await (result as ILiteActionResult).WriteResponse(httpCtx, actionCtx);
}
else
{
httpCtx.Response.ContentType = "application/json";
await httpCtx.Response.WriteAsync(GetJsonSerializer().Serialize(result));
}
}
Invoke
方法检查操作返回类型是 void
、Task
、Task<T>
还是其他类型。根据返回类型,Invoke
方法设置响应代码并写入响应正文。目前,设置自定义响应代码和头部的实现正在为 v0.8 进行。
结束……(还是刚开始?)
如果您能读到这里,我向您致敬,您赢得了我的尊重!我一直在想是否最好将这篇文章分成几个部分(它花费了数周时间),最后,我决定发布一篇涵盖大部分有趣实现细节的文章,并可能后续发布一些关于使用 LiteApi 的更具体的文章(入门、授权、自定义等……)。请告诉我您的想法。是否应该有更多关于 LiteApi 的文章,还是我应该就此打住?
想了解更多?
如果您有兴趣使用 LiteApi 或为它做出贡献,请访问网站和GitHub 仓库。此外,请不要犹豫在下面的评论中问我任何问题。