65.9K
CodeProject 正在变化。 阅读更多。
Home

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

starIconstarIconstarIconstarIconstarIcon

5.00/5 (3投票s)

2017 年 7 月 1 日

CPOL

19分钟阅读

viewsIcon

8478

本文涵盖了 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 应用程序的步骤是:

  1. 从空模板创建 ASP.NET Core Web 应用程序并添加 nuget 包
  2. Startup.cs 中注册 LiteApi
  3. 编写控制器

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 语句和命名空间,并且所有方法都写成了表达式体方法(尽管它们也可以是经典的、老式的)。我们使用的关键命名空间是 LiteApiLiteApi.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# 类,可以位于任何文件夹中,按惯例它通常位于 APIControllers 文件夹中。控制器必须继承 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。
  • HttpGetHttpPostHttpPutHttpDelete 是用于告知中间件操作应响应哪些 HTTP 方法的属性。HttpGet 是可选的。默认情况下,当没有设置 Http...Attribute 时,公共方法被视为 HTTP GET 操作。

现在我们已经了解了 LiteApi 的一些基本功能,是时候看看它是如何工作的了。

LiteApi 内部机制

该中间件的内部工作可以分为两个方面。第一个方面是初始化,第二个方面是运行时处理 HTTP 请求。初始化在第一次运行时完成,负责查找和验证控制器、操作和操作参数。中间件的第二个方面负责在收到 HTTP 请求时查找匹配的控制器和操作。如果找到匹配的控制器/操作,LiteApi 将解释参数、调用操作并返回响应。如果没有找到匹配的控制器/操作,LiteApi 将调用下一个中间件(如果 LiteApi 之后注册了的话)。

请注意,LiteApi 由大约 50 个文件组成(主要是接口和类,不包括测试类和示例项目),本文无法涵盖所有内容,因此您会经常看到呈现的接口和特定方法。接下来的内容将尝试从高层次的角度解释中间件的工作原理,只提供一些实现细节。如果您对所有细节都感兴趣,可以深入研究代码库

中间件的初始化

LiteApi 初始化期间执行以下步骤:

  1. 注册控制器
    • 查找应用程序程序集并找到控制器
    • 为每个控制器查找操作
    • 为每个操作查找输入参数和结果响应类型
  2. 执行验证
    • 验证所有控制器
    • 为每个控制器验证操作
    • 为每个操作验证参数

控制器、操作和操作参数的注册

对于控制器、操作和参数的注册,我们有以下接口:

public interface IControllerDiscoverer
{
    ControllerContext[] GetControllers(Assembly assembly);
}

public interface IActionDiscoverer
{
    ActionContext[] GetActions(ControllerContext controllerCtx);
}

public interface IParametersDiscoverer
{
    ActionParameter[] GetParameters(ActionContext actionCtx);
}

ControllerContextActionContextActionParameter 是保存控制器元数据的类。接口应该是不言自明的。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 为每个方法创建一个 ActionContextActionContext 获取方法名称,该名称可以设置为路由段。如果父控制器是 RESTful 的,则路由段为空,除非存在 ActionRouteAttribute。路由段用于将请求路由匹配到控制器和操作,有关路由段匹配的更多信息将在本文后面进行描述。对于每个操作上下文,GetActionContext 方法会调用 IParametersDiscoverer.GetParameters

LiteApi 操作中的参数可以通过以下方式接收:

  • 查询(简单类型的默认参数源)
  • 路由段(在 ActionRouteAttribute 中设置)
  • 正文(对于不被视为简单类型的类型,其默认参数源)
  • 头部(可以通过 FromHeaderAttribute 设置)
  • 依赖注入容器(可以通过 FromServicesAttribute 设置)

发现和解析参数可能是中间件执行的最具挑战性的工作。参数可以来自不同的来源,并且不同来源的参数有不同的规则。例如,可空参数不允许出现在路由段中,正文参数不允许出现在 GETDELETE 请求中,集合和字典不允许出现在头部中……除了这些规则之外,更复杂的规则是确定操作重载的规则。例如,我们来看以下三个操作:

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 解析为 Guidint。如果参数成功解析为 Guid,它将调用期望 Guid 的操作;如果参数成功解析为整数,它将调用期望 int 的操作;如果解析不成功,它将最终调用期望 string 的操作。更复杂的情况是操作被重载为 intint?int?[]int[]。在这种情况下,LiteApi 将始终调用 int?[],因为这种类型的参数是其他三种参数类型的超集。在重载 intint?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 请求。中间件在收到请求时执行的步骤如下:

  1. 为请求创建日志记录器(每个请求一个日志记录器)
  2. 查找要调用的操作
    • 如果找到操作
      • 检查是否需要 HTTPS(如果需要且请求不是 HTTPS,则返回 400)
      • 调用操作
    • 如果没有找到操作,则查找下一个中间件并调用它(如果存在下一个中间件)

调用操作不是一个步骤,它包括运行过滤器(例如,授权)、创建控制器实例、解析参数和调用操作。下图描述了响应 HTTP 请求时所采取步骤的高层概述。

LiteApi

这是位于中间件类中的接收 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;
}

GetActionsBySegmentsControllerContext 类中的一个方法。该类用于存储控制器的元数据。在其他重要信息中,用于查找正确控制器/操作的属性是 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);
}

操作调用器接受 HttpContextActionContextILoggerActionContext 是匹配请求的操作,ILogger 是上下文感知日志记录器,它会记录请求 ID 以及任何其他信息,因此在调试时我们可以看到并跟踪单个请求的日志。IActionInvoker 的默认实现使用反射。计划(为将来发布)实现一个更快的调用器,该调用器使用编译表达式、委托或 IL 发射。目前,LiteApi 比 MVC 快,一旦实现新的调用器,它应该会更快。

操作调用器返回 Task,因为操作可以是 async Task

调用操作时执行的步骤如下:

  1. 运行过滤器
    • 如果过滤器失败,则写入带有 4xx 状态码和错误描述的响应
  2. 构造控制器
  3. 解析参数(如果有)
  4. 调用操作并获取结果
  5. 检查结果是 Task、void 还是其他对象
    • 如果结果是 Task,则 await 它
      • 如果 await 结果不是 void,则序列化响应
    • 否则,如果结果不是 void,则序列化响应
  6. 设置响应代码(2xx)
  7. 如果存在序列化响应,则设置响应内容类型并将响应写入响应正文

调用操作的方法和类很长,因此我将代码分成几个步骤。如果您对整个操作调用器类感兴趣,请查看 GitHub 上的文件

1. 运行过滤器步骤负责检查 RequireHttpsAttribute 和授权属性。这些属性被称为过滤器。每个过滤器都实现 IApiFilterIApiFilterAsync,并且检查的结果是返回 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 连接的要求无效,因为 RequiresHttpsAttributeIgnoreSkipFilters 属性设置为 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 方法检查操作返回类型是 voidTaskTask<T> 还是其他类型。根据返回类型,Invoke 方法设置响应代码并写入响应正文。目前,设置自定义响应代码和头部的实现正在为 v0.8 进行。

结束……(还是刚开始?)

如果您能读到这里,我向您致敬,您赢得了我的尊重!我一直在想是否最好将这篇文章分成几个部分(它花费了数周时间),最后,我决定发布一篇涵盖大部分有趣实现细节的文章,并可能后续发布一些关于使用 LiteApi 的更具体的文章(入门、授权、自定义等……)。请告诉我您的想法。是否应该有更多关于 LiteApi 的文章,还是我应该就此打住?

想了解更多?

如果您有兴趣使用 LiteApi 或为它做出贡献,请访问网站GitHub 仓库。此外,请不要犹豫在下面的评论中问我任何问题。

 

© . All rights reserved.