ASP.NET Core静态文件中间件[4]: StaticFileMiddleware 中间件全解析

上面的实例演示(搭建文件服务器条件请求以提升性能区间请求以提供部分内容)从提供的功能和特性的角度对StaticFileMiddleware中间件进行了全面的介绍,下面从实现原理的角度对这个中间件进行全面解析。

public class StaticFileMiddleware
{
    public StaticFileMiddleware(RequestDelegate next, IWebHostEnvironment hostingEnv, options, ILoggerFactory loggerFactory);
    public Task Invoke(HttpContext context);
}

目录
一、配置选项StaticFileOptions
二、扩展方法UseStaticFiles
三、媒体类型的解析
四、完整处理流程
     获取目标文件
     条件请求解析
     请求区间解析
     设置响应报头
     发送响应

一、配置选项StaticFileOptions

如上面的代码片段所示,除了用来将当前请求分发给后续管道的参数next,StaticFileMiddleware的构造函数还包含3个参数。其中,参数hostingEnv和参数loggerFactory分别表示当前承载环境与用来创建ILogger的ILoggerFactory对象,最重要的参数options表示为这个中间件指定的配置选项。至于具体可以提供什么样的配置选项,我们只需要了解StaticFileOptions类型提供了什么样的属性成员。StaticFileOptions继承自如下所示的抽象类SharedOptionsBase。基类SharedOptionsBase定义了请求路径与对应IFileProvider对象之间的映射关系(默认为PhysicalFileProvider)。

public abstract class SharedOptionsBase
{
    protected SharedOptions SharedOptions { get; private set; }
    public PathString RequestPath { get => SharedOptions.RequestPath; set => SharedOptions.RequestPath = value; }
    public IFileProvider FileProvider 
    { 
        get => SharedOptions.FileProvider; 
        set => SharedOptions.FileProvider = value; 
    }
    protected SharedOptionsBase(SharedOptions sharedOptions) => SharedOptions = sharedOptions;
}

public class SharedOptions
{
    public PathString RequestPath { get; set; } = PathString.Empty;
    public IFileProvider FileProvider { get; set; }
}

定义在StaticFileOptions中的前三个属性都与媒体类型的解析有关,其中ContentTypeProvider属性返回一个根据请求相对地址解析出媒体类型的IContentTypeProvider对象。如果这个IContentTypeProvider对象无法正确解析出目标文件的媒体类型,就可以利用DefaultContentType设置一个默认媒体类型。但只有将另一个名为ServeUnknownFileTypes的属性设置为True,中间件才会采用这个默认设置的媒体类型。

public class StaticFileOptions : SharedOptionsBase
{
    public IContentTypeProvider ContentTypeProvider { get; set; }
    public string DefaultContentType { get; set; }
    public bool ServeUnknownFileTypes { get; set; }
    public HttpsCompressionMode HttpsCompression { get; set; }
    public Action<StaticFileResponseContext> OnPrepareResponse { get; set; }

    public StaticFileOptions();
    public StaticFileOptions(SharedOptions sharedOptions);
}

public enum HttpsCompressionMode
{
    Default = 0,
    DoNotCompress,
    Compress
}

public class StaticFileResponseContext
{
    public HttpContext Context { get; }
    public IFileInfo File { get; }
}

二、扩展方法UseStaticFiles

StaticFileOptions的HttpsCompression属性表示在压缩中间件存在的情况下,采用HTTPS方法请求的文件是否应该被压缩,该属性的默认值为Compress(即默认情况下会对文件进行压缩)。StaticFileOptions还有一个OnPrepareResponse属性,它返回一个Action<StaticFileResponseContext>类型的委托对象,利用这个委托对象可以对最终的响应进行定制。作为输入的StaticFileResponseContext对象可以提供表示当前HttpContext上下文和描述目标文件的IFileInfo对象。

针对StaticFileMiddleware中间件的注册一般都是调用针对IApplicationBuilder对象的UseStaticFiles扩展方法来完成的。如下面的代码片段所示,我们共有3个UseStaticFiles方法重载可供选择。

public static class StaticFileExtensions
{
    public static IApplicationBuilder UseStaticFiles(this IApplicationBuilder app) => app.UseMiddleware<StaticFileMiddleware>();

    public static IApplicationBuilder UseStaticFiles(this IApplicationBuilder app, StaticFileOptions options) 
        => app.UseMiddleware<StaticFileMiddleware>(Options.Create<StaticFileOptions>(options));

    public static IApplicationBuilder UseStaticFiles(this IApplicationBuilder app, string requestPath)
    {       
        var options = new StaticFileOptions
        {
            RequestPath = new PathString(requestPath)
        };
        return app.UseStaticFiles(options);
    }
}

三、媒体类型的解析

StaticFileMiddleware中间件针对静态文件请求的处理并不仅限于完成文件内容的响应,它还需要为目标文件提供正确的媒体类型。对于客户端来说,如果无法确定媒体类型,获取的文件就像是一部无法解码的天书,毫无价值。StaticFileMiddleware中间件利用指定的IContentTypeProvider对象来解析媒体类型。如下面的代码片段所示,IContentTypeProvider接口定义了唯一的方法TryGetContentType,从而根据当前请求的相对路径来解析这个作为输出参数的媒体类型。

public interface IContentTypeProvider
{
    bool TryGetContentType(string subpath, out string contentType);
}

StaticFileMiddleware中间件默认使用的是一个具有如下定义的FileExtensionContentTypeProvider类型。顾名思义,FileExtensionContentTypeProvider利用物理文件的扩展名来解析对应的媒体类型,并利用其Mappings属性表示的字典维护了扩展名与媒体类型之间的映射关系。常用的数百种标准的文件扩展名和对应的媒体类型之间的映射关系都会保存在这个字典中。如果发布的文件具有一些特殊的扩展名,或者需要将现有的某些扩展名映射为不同的媒体类型,都可以通过添加或者修改扩展名/媒体类型之间的映射关系来实现。

public class FileExtensionContentTypeProvider : IContentTypeProvider
{
    public IDictionary<string, string> Mappings { get; }

    public FileExtensionContentTypeProvider();
    public FileExtensionContentTypeProvider(IDictionary<string, string> mapping);

    public bool TryGetContentType(string subpath, out string contentType);
}

四、完整处理流程

为了使读者对针对静态文件的请求在StaticFileMiddleware中间件的处理有更加深刻的认识,下面采用相对简单的代码来重新定义这个中间件。这个模拟中间件具有与StaticFileMiddleware相同的能力,它能够将目标文件的内容采用正确的媒体类型响应给客户端,同时能够处理条件请求和区间请求。StaticFileMiddleware中间件处理针对静态文件请求的整个处理流程大体上可以划分为如下3个步骤。

  • 获取目标文件:中间件根据请求的路径获取目标文件,并解析出正确的媒体类型。在此之前,中间件还会验证请求采用的HTTP方法是否有效,它只支持GET请求和HEAD请求。中间件还会获取文件最后被修改的时间,并根据这个时间戳和文件内容的长度生成一个标签,响应报文的Last-Modified报头和ETag报头的内容就来源于此。
  • 条件请求解析:获取与条件请求相关的4个报头(If-Match、If-None-Match、If-Modified-Since和If-Unmodified-Since)的值,根据HTTP规范计算出最终的条件状态。
  • 响应请求:如果是区间请求,中间件会提取相关的报头(Range和If-Range)并解析出正确的内容区间。中间件最终根据上面计算的条件状态和区间相关信息设置响应报头,并根据需要响应整个文件的内容或者指定区间的内容。

下面按照上述流程重新定义StaticFileMiddleware中间件,但在此之前需要先介绍预先定义的几个辅助性的扩展方法。如下面的代码片段所示,UseMethods扩展方法用于确定请求是否采用指定的HTTP方法,而TryGetSubpath方法用于解析请求的目标文件的相对路径。TryGetContentType方法会根据指定的StaticFileOptions携带的IContentTypeProvider对象解析出正确的媒体类型,而TryGetFileInfo方法则根据指定的路径获取描述目标文件的IFileInfo对象。IsRangeRequest方法会根据是否携带Rang报头判断指定的请求是否是一个区间请求。

public static class Extensions
{
    public static bool UseMethods(this HttpContext context, params string[] methods) 
        => methods.Contains(context.Request.Method, StringComparer.OrdinalIgnoreCase);

    public static bool TryGetSubpath(this HttpContext context, string requestPath, out PathString subpath)
        => new PathString(context.Request.Path).StartsWithSegments(requestPath, out subpath);

    public static bool TryGetContentType(this StaticFileOptions options, PathString subpath, out string contentType)
        => options.ContentTypeProvider.TryGetContentType(subpath.Value, out contentType) || (!string.IsNullOrEmpty(contentType = options.DefaultContentType) && options.ServeUnknownFileTypes);

    public static bool TryGetFileInfo(this StaticFileOptions options, PathString subpath, out IFileInfo fileInfo)
        => (fileInfo = options.FileProvider.GetFileInfo(subpath.Value)).Exists;

    public static bool IsRangeRequest(this HttpContext context)
        => context.Request.GetTypedHeaders().Range != null;
}

模拟类型 StaticFileMiddleware的定义如下。如果指定的StaticFileOptions没有提供IFileProvider对象,我们会创建一个针对WebRoot目录的PhysicalFileProvider对象。如果一个具体的IContentTypeProvider对象没有显式指定,我们使用的就是一个FileExtensionContentTypeProvider对象。这两个默认值分别解释了两个问题:为什么请求的静态文件将WebRoot作为默认的根目录,为什么目标文件的扩展名会决定响应的媒体类型。

public class StaticFileMiddleware
{
    private readonly RequestDelegate _next;
    private readonly StaticFileOptions _options;

    public StaticFileMiddleware(RequestDelegate next, IWebHostEnvironment env, IOptions<StaticFileOptions> options)
    {
        _next = next;
        _options = options.Value;
        _options.FileProvider = _options.FileProvider ?? env.WebRootFileProvider;
        _options.ContentTypeProvider = _options.ContentTypeProvider ?? new FileExtensionContentTypeProvider();
    }
    ...
}

上述3个步骤分别实现在对应的方法(TryGetFileInfo、GetPreconditionState和SendResponseAsync)中,所以StaticFileMiddleware中间件类型的InvokeAsync方法按照如下方式先后调用这3个方法完成对整个文件请求的处理。

public class StaticFileMiddleware
{
    public async Task InvokeAsync(HttpContext context)
    {
        if (this.TryGetFileInfo(context, out var contentType, out var fileInfo, out var lastModified, out var etag))
        {
            var preconditionState = GetPreconditionState(context, lastModified.Value, etag);
            await SendResponseAsync(preconditionState, context, etag, lastModified.Value, contentType, fileInfo);
            return;
        }
        await _next(context);
    }    
    ...
}

获取目标文件

下面重点介绍这3个方法的实现。首先介绍TryGetFileInfo方法是如何根据请求的路径获得描述目标文件的IFileInfo对象的。如下面的代码片段所示,如果目标文件存在,这个方法除了将目标文件的IFileInfo对象作为输出参数返回,与这个文件相关的数据(媒体类型、最后修改时间戳和封装标签的ETag)也会一并返回。

public class StaticFileMiddleware
{
    public bool TryGetFileInfo(HttpContext context, out string contentType, out IFileInfo fileInfo, out DateTimeOffset? lastModified, out EntityTagHeaderValue etag)
    {
        contentType = null;
        fileInfo = null;

        if (context.UseMethods("GET", "HEAD") && context.TryGetSubpath(_options.RequestPath, out var subpath) &&
            _options.TryGetContentType(subpath, out contentType) && _options.TryGetFileInfo(subpath, out fileInfo))
        {
            var last = fileInfo.LastModified;
            long etagHash = last.ToFileTime() ^ fileInfo.Length;
            etag = new EntityTagHeaderValue('\"' + Convert.ToString(etagHash, 16) + '\"');
            lastModified = new DateTimeOffset(last.Year, last.Month, last.Day, last.Hour, last.Minute, last.Second, last.Offset).ToUniversalTime();
            return true;
        }

        etag = null;
        lastModified = null;
        return false;
    }
}

GetPreconditionState方法旨在获取与条件请求相关的4个报头(If-Match、If-None-Match、If-Modified-Since和If-Unmodified-Since)的值,并通过与目标文件当前的状态进行比较,进而得到一个最终的检验结果。针对这4个请求报头的检验最终会产生4种可能的结果,所以我们定义了如下所示的一个PreconditionState枚举来表示它们。

private enum PreconditionState
{
    Unspecified = 0,
    NotModified = 1,
    ShouldProcess = 2,
    PreconditionFailed = 3,
}

对于定义在这个枚举类型中的4个选项,Unspecified表示请求中不包含这4个报头。如果将请求报头If-None-Match的值与当前文件标签进行比较,或者将请求报头If-Modified-Since的值与文件最后修改时间进行比较确定目标文件不曾被更新,检验结果对应的枚举值为NotModified,反之则对应的枚举值为ShouldProcess。

条件请求解析

如果目标文件当前的状态不满足If-Match报头或者If-Unmodified-Since报头表示的条件,那么检验结果对应的枚举值为PreconditionFailed;反之,对应的枚举值为ShouldProcess。如果请求携带多个报头,针对它们可能会得出不同的检验结果,那么值最大的那个将作为最终的结果。如下面的代码片段所示,GetPreconditionState方法正是通过这样的逻辑得到表示最终条件检验结果的PreconditionState枚举的。

public class StaticFileMiddleware
{
    private PreconditionState GetPreconditionState(HttpContext context,DateTimeOffset lastModified, EntityTagHeaderValue etag)
    {
        PreconditionState ifMatch, ifNonematch, ifModifiedSince, ifUnmodifiedSince;
        ifMatch = ifNonematch = ifModifiedSince = ifUnmodifiedSince = PreconditionState.Unspecified;

        var requestHeaders = context.Request.GetTypedHeaders();
        //If-Match:ShouldProcess or PreconditionFailed
        if (requestHeaders.IfMatch != null)
        {
            ifMatch = requestHeaders.IfMatch.Any(it
                => it.Equals(EntityTagHeaderValue.Any) || it.Compare(etag, true))
                ? PreconditionState.ShouldProcess
                : PreconditionState.PreconditionFailed;
        }

        //If-None-Match:NotModified or ShouldProcess
        if (requestHeaders.IfNoneMatch != null)
        {
            ifNonematch = requestHeaders.IfNoneMatch.Any(it => it.Equals(EntityTagHeaderValue.Any) || it.Compare(etag, true))
                ? PreconditionState.NotModified
                : PreconditionState.ShouldProcess;
        }

        //If-Modified-Since: ShouldProcess or NotModified
        if (requestHeaders.IfModifiedSince.HasValue)
        {
            ifModifiedSince = requestHeaders.IfModifiedSince < lastModified
                ? PreconditionState.ShouldProcess
                : PreconditionState.NotModified;
        }

        //If-Unmodified-Since: ShouldProcess or PreconditionFailed
        if (requestHeaders.IfUnmodifiedSince.HasValue)
        {
            ifUnmodifiedSince = requestHeaders.IfUnmodifiedSince > lastModified
                ? PreconditionState.ShouldProcess
                : PreconditionState.PreconditionFailed;
        }

        //Return maximum.
        return new PreconditionState[] {ifMatch, ifNonematch, ifModifiedSince, ifUnmodifiedSince }.Max();
    }
    ...
}

请求区间解析

针对静态文件的处理最终在SendResponseAsync方法中实现,这个方法会设置相应的响应报头和状态码,如果需要,它还会将目标文件的内容写入响应报文的主体中。为响应选择什么样的状态码,设置哪些报头,以及响应主体内容的设置除了决定于GetPreconditionState方法返回的检验结果,与区间请求相关的两个报头(Range和If-Range)也是决定性因素之一。所以,我们定义了如下所示的TryGetRanges方法,用于解析这两个报头并计算出正确的区间。

public class StaticFileMiddleware
{
    private bool TryGetRanges(HttpContext context, DateTimeOffset lastModified, EntityTagHeaderValue etag, long length, out IEnumerable<RangeItemHeaderValue> ranges)
    {
        ranges = null;
        var requestHeaders = context.Request.GetTypedHeaders();

        //Check If-Range
        var ifRange = requestHeaders.IfRange;
        if (ifRange != null)
        {
            bool ignore = (ifRange.EntityTag != null && !ifRange.EntityTag.Compare(etag, true)) || (ifRange.LastModified.HasValue && ifRange.LastModified < lastModified);
            if (ignore)
            {
                return false;
            }
        }

        var list = new List<RangeItemHeaderValue>();
        foreach (var it in requestHeaders.Range.Ranges)
        {
            //Range:{from}-{to} Or {from}-
            if (it.From.HasValue)
            {
                if (it.From.Value < length - 1)
                {
                    long to = it.To.HasValue
                        ? Math.Min(it.To.Value, length - 1)
                        : length - 1;
                    list.Add(new RangeItemHeaderValue(it.From.Value, to));
                }
            }
            //Range:-{size}
            else if (it.To.Value != 0)
            {
                long size = Math.Min(length, it.To.Value);
                list.Add(new RangeItemHeaderValue(length - size, length - 1));
            }
        }
        return (ranges = list) != null;
    }
    ...
}

如上面的代码片段所示,TryGetRanges方法先获取If-Range报头的值,并将它与目标文件当前的状态进行比较。如果当前状态不满足If-Range报头表示的条件,就意味着目标文件内容发生变化,那么请求Range报头携带的区间信息将自动被忽略。而Range报头携带的值具有不同的表现形式(如bytes={from}-{to}、bytes={from}-和bytes=-{size}),并且指定的端点有可能超出目标文件的长度,所以TryGetRanges方法定义了相应的逻辑来检验区间定义的合法性并计算出正确的区间范围。

对于区间请求,TryGetRanges方法的返回值表示目标文件的当前状态是否与If-Range报头携带的条件相匹配。由于HTTP规范并未限制Range报头中设置的区间数量(原则上可以指定多个区间),所以TryGetRanges方法通过输出参数返回的区间信息是一个元素类型为RangeItemHeaderValue的集合。如果集合为空,就表示设置的区间不符合要求。

设置响应报头

实现在SendResponseAsync方法中针对请求的处理基本上是指定响应状态码、设置响应报头和写入响应主体内容。我们将前两项工作实现在HttpContext如下所示的SetResponseHeaders扩展方法中。该方法不仅可以将指定的响应状态码应用到HttpContext上,还可以设置相应的响应报头。

public static class Extensions
{
    public static void SetResponseHeaders(this HttpContext context, int statusCode, EntityTagHeaderValue etag, DateTimeOffset lastModified, string contentType, long contentLength, RangeItemHeaderValue range = null)
    {
        context.Response.StatusCode = statusCode;
        var responseHeaders = context.Response.GetTypedHeaders();
        if (statusCode < 400)
        {
            responseHeaders.ETag = etag;
            responseHeaders.LastModified = lastModified;
            context.Response.ContentType = contentType;
            context.Response.Headers[HeaderNames.AcceptRanges] = "bytes";
        }
        if (statusCode == 200)
        {
            context.Response.ContentLength = contentLength;
        }

        if (statusCode == 416)
        {
            responseHeaders.ContentRange = new ContentRangeHeaderValue(contentLength);
        }

        if (statusCode == 206 && range != null)
        {
            responseHeaders.ContentRange = new ContentRangeHeaderValue(range.From.Value, range.To.Value, contentLength);
        }
    }
}

如上面的代码片段所示,对于所有非错误类型的响应(主要是指状态码为“200 OK”、“206 Partial Content”和“304 Not Modified”的响应),除了表示媒体类型的Content-Type报头,还有3个额外的报头(Last-Modified、ETag和Accept-Range)。针对区间请求的两种响应(“206 Partial Content”和“416 Range Not Satisfiable”)都有一个Content-Range报头。

发送响应

如下所示的代码片段是SendResponseAsync方法的完整定义。它会根据条件请求和区间请求的解析结果来决定最终采用的响应状态码。响应状态和相关响应报头的设置是通过调用上面的SetResponseHeaders方法来完成的。对于状态码为“200 OK”或者“206 Partial Content”的响应,SetResponseHeaders方法会将整个文件的内容或者指定区间的内容写入响应报文的主体部分。而文件内容的读取则调用表示目标文件的FileInfo对象的CreateReadStream方法,并利用其返回的输出流来实现。

public class StaticFileMiddleware
{
    private async Task SendResponseAsync(PreconditionState state, HttpContext context, EntityTagHeaderValue etag, DateTimeOffset lastModified, string contentType, IFileInfo fileInfo)
    {
        switch (state)
        {
            //304 Not Modified
            case PreconditionState.NotModified:
                {
                    context.SetResponseHeaders(304, etag, lastModified, contentType, fileInfo.Length);
                    break;
                }
            //416 Precondition Failded
            case PreconditionState.PreconditionFailed:
                {
                    context.SetResponseHeaders(412, etag, lastModified, contentType, fileInfo.Length);
                    break;
                }
            case PreconditionState.Unspecified:
            case PreconditionState.ShouldProcess:
                {
                    //200 OK
                    if (context.UseMethods("HEAD"))
                    {
                        context.SetResponseHeaders(200, etag, lastModified, contentType, fileInfo.Length);
                        return;
                    }

                    IEnumerable<RangeItemHeaderValue> ranges;
                    if (context.IsRangeRequest() && this.TryGetRanges(context, lastModified, etag, fileInfo.Length, out ranges))
                    {
                        RangeItemHeaderValue range = ranges.FirstOrDefault();
                        //416 
                        if (null == range)
                        {
                            context.SetResponseHeaders(416, etag, lastModified, contentType, fileInfo.Length);
                            return;
                        }
                        else
                        {
                            //206 Partial Content
                            context.SetResponseHeaders(206, etag, lastModified, contentType, fileInfo.Length, range);
                            context.Response.GetTypedHeaders().ContentRange = new ContentRangeHeaderValue(range.From.Value, range.To.Value, fileInfo.Length);
                            using (Stream stream = fileInfo.CreateReadStream())
                            {
                                stream.Seek(range.From.Value, SeekOrigin.Begin);
                                await StreamCopyOperation.CopyToAsync(stream, context.Response.Body, range.To - range.From + 1, context.RequestAborted);
                            }
                            return;
                        }
                    }
                    //200 OK
                    context.SetResponseHeaders(200, etag, lastModified, contentType, fileInfo.Length);
                    using (Stream stream = fileInfo.CreateReadStream())
                    {
                        await StreamCopyOperation.CopyToAsync(stream, context.Response.Body, fileInfo.Length, context.RequestAborted);
                    }
                    break;
                }
        }
    }
}

静态文件中间件[1]: 搭建文件服务器
静态文件中间件[2]: 条件请求以提升性能
静态文件中间件[3]: 区间请求以提供部分内容
静态文件中间件[4]: StaticFileMiddleware
静态文件中间件[5]: DirectoryBrowserMiddleware & DefaultFilesMiddleware

发表评论

相关文章