浅谈ASP.NET Core静态文件处理源码探究

前言

    静态文件(如 HTML、CSS、图像和 JavaScript)等是Web程序的重要组成部分。传统的ASP.NET项目一般都是部署在IIS上,IIS是一个功能非常强大的服务器平台,可以直接处理接收到的静态文件处理而不需要经过应用程序池处理,所以很多情况下对于静态文件的处理程序本身是无感知的。ASP.NET Core则不同,作为Server的Kestrel服务是宿主到程序上的,由宿主运行程序启动Server然后可以监听请求,所以通过程序我们直接可以处理静态文件相关。静态文件默认存储到项目的wwwroot目录中,当然我们也可以自定义任意目录去处理静态文件。总之,在ASP.NET Core我们可以处理静态文件相关的请求。

StaticFile三剑客

    通常我们在说道静态文件相关的时候会涉及到三个话题分别是启用静态文件、默认静态页面、静态文件目录浏览,在ASP.NET Core分别是通过UseStaticFiles、UseDefaultFiles、UseDirectoryBrowser三个中间件去处理。只有配置了相关中间件才能去操作对应的处理,相信大家对这种操作已经很熟了。静态文件操作相关的源码都位于GitHub aspnetcore仓库中的https://github.com/dotnet/aspnetcore/tree/v3.1.6/src/Middleware/StaticFiles/src目录。接下来我们分别探究这三个中间件的相关代码,来揭开静态文件处理的神秘面纱。

UseStaticFiles

UseStaticFiles中间件使我们处理静态文件时最常使用的中间件,因为只有开启了这个中间件我们才能使用静态文件,比如在使用MVC开发的时候需要私用js css html等文件都需要用到它,使用的方式也比较简单

//使用默认路径,即wwwroot
app.UseStaticFiles();
//或自定义读取路径
var fileProvider = new PhysicalFileProvider($"{env.ContentRootPath}/staticfiles");
app.UseStaticFiles(new StaticFileOptions {
  RequestPath="/staticfiles",
  FileProvider = fileProvider
});

我们直接找到中间件的注册类StaticFileExtensions[点击查看StaticFileExtensions源码]

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

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

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

一般我们最常用到的是无参的方式和传递自定义StaticFileOptions的方式比较多,StaticFileOptions是自定义使用静态文件时的配置信息类,接下来我们大致看一下具体包含哪些配置项[点击查看StaticFileOptions源码]

public class StaticFileOptions : SharedOptionsBase
{
  public StaticFileOptions() : this(new SharedOptions())
  {
  }

  public StaticFileOptions(SharedOptions sharedOptions) : base(sharedOptions)
  {
    OnPrepareResponse = _ => { };
  }

  /// <summary>
  /// 文件类型提供程序,也就是我们常用的文件名对应MimeType的对应关系
  /// </summary>
  public IContentTypeProvider ContentTypeProvider { get; set; }

  /// <summary>
  /// 设置该路径下默认文件输出类型
  /// </summary>
  public string DefaultContentType { get; set; }

  public bool ServeUnknownFileTypes { get; set; }
  
  /// <summary>
  /// 文件压缩方式
  /// </summary>
  public HttpsCompressionMode HttpsCompression { get; set; } = HttpsCompressionMode.Compress;

  /// <summary>
  /// 准备输出之前可以做一些自定义操作
  /// </summary>
  public Action<StaticFileResponseContext> OnPrepareResponse { get; set; }
}

public abstract class SharedOptionsBase
{
  protected SharedOptionsBase(SharedOptions sharedOptions)
  {
    SharedOptions = sharedOptions;
  }

  protected SharedOptions SharedOptions { get; private set; }
  
  /// <summary>
  /// 请求路径
  /// </summary>
  public PathString RequestPath
  {
    get { return SharedOptions.RequestPath; }
    set { SharedOptions.RequestPath = value; }
  }

  /// <summary>
  /// 文件提供程序,在.NET Core中如果需要访问文件相关操作可使用FileProvider文件提供程序获取文件相关信息
  /// </summary>
  public IFileProvider FileProvider
  {
    get { return SharedOptions.FileProvider; }
    set { SharedOptions.FileProvider = value; }
  }
}

我们自定义静态文件访问时,最常用到的就是RequestPath和FileProvider,一个设置请求路径信息,一个设置读取文件信息。如果需要自定义MimeType映射关系可通过ContentTypeProvider自定义设置映射关系

var provider = new FileExtensionContentTypeProvider();
provider.Mappings[".myapp"] = "application/x-msdownload";
provider.Mappings[".htm3"] = "text/html";
app.UseStaticFiles(new StaticFileOptions
{
  ContentTypeProvider = provider,
  //可以在输出之前设置输出相关
  OnPrepareResponse = ctx =>
  {
    ctx.Context.Response.Headers.Append("Cache-Control", $"public, max-age=3600");
  }
});

接下来我们步入正题直接查看StaticFileMiddleware中间件的代码[点击查看StaticFileMiddleware源码]

public class StaticFileMiddleware
{
  private readonly StaticFileOptions _options;
  private readonly PathString _matchUrl;
  private readonly RequestDelegate _next;
  private readonly ILogger _logger;
  private readonly IFileProvider _fileProvider;
  private readonly IContentTypeProvider _contentTypeProvider;

  public StaticFileMiddleware(RequestDelegate next, IWebHostEnvironment hostingEnv, IOptions<StaticFileOptions> options, ILoggerFactory loggerFactory)
  {
    _next = next;
    _options = options.Value;
    //设置文件类型提供程序
    _contentTypeProvider = options.Value.ContentTypeProvider ?? new FileExtensionContentTypeProvider();
    //文件提供程序
    _fileProvider = _options.FileProvider ?? Helpers.ResolveFileProvider(hostingEnv);
    //匹配路径
    _matchUrl = _options.RequestPath;
    _logger = loggerFactory.CreateLogger<StaticFileMiddleware>();
  }

  public Task Invoke(HttpContext context)
  {
    //判断是够获取到终结点信息,这也就是为什么我们使用UseStaticFiles要在UseRouting之前
    if (!ValidateNoEndpoint(context))
    {
    }
    //判断HttpMethod,只能是Get和Head操作
    else if (!ValidateMethod(context))
    {
    }
    //判断请求路径是否存在
    else if (!ValidatePath(context, _matchUrl, out var subPath))
    {
    }
    //根据请求文件名称判断是否可以匹配到对应的MimeType,如果匹配到则返回contentType
    else if (!LookupContentType(_contentTypeProvider, _options, subPath, out var contentType))
    {
    }
    else
    {  
      //执行静态文件操作
      return TryServeStaticFile(context, contentType, subPath);
    }
    return _next(context);
  }

  private Task TryServeStaticFile(HttpContext context, string contentType, PathString subPath)
  {
    var fileContext = new StaticFileContext(context, _options, _logger, _fileProvider, contentType, subPath);
    //判断文件是否存在
    if (!fileContext.LookupFileInfo())
    {
      _logger.FileNotFound(fileContext.SubPath);
    }
    else
    {  
      //静态文件处理
      return fileContext.ServeStaticFile(context, _next);
    }
    return _next(context);
  }
}

关于FileExtensionContentTypeProvider这里就不作讲解了,主要是承载文件扩展名和MimeType的映射关系代码不复杂,但是映射关系比较多,有兴趣的可以自行查看FileExtensionContentTypeProvider源码,通过上面我们可以看到,最终执行文件相关操作的是StaticFileContext类[点击查看StaticFileContext源码]

internal struct StaticFileContext
{
  private const int StreamCopyBufferSize = 64 * 1024;

  private readonly HttpContext _context;
  private readonly StaticFileOptions _options;
  private readonly HttpRequest _request;
  private readonly HttpResponse _response;
  private readonly ILogger _logger;
  private readonly IFileProvider _fileProvider;
  private readonly string _method;
  private readonly string _contentType;

  private IFileInfo _fileInfo;
  private EntityTagHeaderValue _etag;
  private RequestHeaders _requestHeaders;
  private ResponseHeaders _responseHeaders;
  private RangeItemHeaderValue _range;

  private long _length;
  private readonly PathString _subPath;
  private DateTimeOffset _lastModified;

  private PreconditionState _ifMatchState;
  private PreconditionState _ifNoneMatchState;
  private PreconditionState _ifModifiedSinceState;
  private PreconditionState _ifUnmodifiedSinceState;

  private RequestType _requestType;

  public StaticFileContext(HttpContext context, StaticFileOptions options, ILogger logger, IFileProvider fileProvider, string contentType, PathString subPath)
  {
    _context = context;
    _options = options;
    _request = context.Request;
    _response = context.Response;
    _logger = logger;
    _fileProvider = fileProvider;
    _method = _request.Method;
    _contentType = contentType;
    _fileInfo = null;
    _etag = null;
    _requestHeaders = null;
    _responseHeaders = null;
    _range = null;

    _length = 0;
    _subPath = subPath;
    _lastModified = new DateTimeOffset();
    _ifMatchState = PreconditionState.Unspecified;
    _ifNoneMatchState = PreconditionState.Unspecified;
    _ifModifiedSinceState = PreconditionState.Unspecified;
    _ifUnmodifiedSinceState = PreconditionState.Unspecified;
    //再次判断请求HttpMethod
    if (HttpMethods.IsGet(_method))
    {
      _requestType = RequestType.IsGet;
    }
    else if (HttpMethods.IsHead(_method))
    {
      _requestType = RequestType.IsHead;
    }
    else
    {
      _requestType = RequestType.Unspecified;
    }
  }

  /// <summary>
  /// 判断文件是否存在
  /// </summary>
  public bool LookupFileInfo()
  {
    //判断根据请求路径是否可以获取到文件信息
    _fileInfo = _fileProvider.GetFileInfo(_subPath.Value);
    if (_fileInfo.Exists)
    {
      //获取文件长度
      _length = _fileInfo.Length;
      //最后修改日期
      DateTimeOffset last = _fileInfo.LastModified;
      _lastModified = new DateTimeOffset(last.Year, last.Month, last.Day, last.Hour, last.Minute, last.Second, last.Offset).ToUniversalTime();
      //ETag标识
      long etagHash = _lastModified.ToFileTime() ^ _length;
      _etag = new EntityTagHeaderValue('\"' + Convert.ToString(etagHash, 16) + '\"');
    }
    return _fileInfo.Exists;
  }
  
  /// <summary>
  /// 处理文件输出
  /// </summary>
  public async Task ServeStaticFile(HttpContext context, RequestDelegate next)
  {
    //1.准备输出相关Header,主要是获取和输出静态文件输出缓存相关的内容
    //2.我们之前提到的OnPrepareResponse也是在这里执行的
    ComprehendRequestHeaders();
    //根据ComprehendRequestHeaders方法获取到的文件状态进行判断
    switch (GetPreconditionState())
    {
      case PreconditionState.Unspecified:
      //处理文件输出
      case PreconditionState.ShouldProcess:
        //判断是否是Head请求
        if (IsHeadMethod)
        {
          await SendStatusAsync(Constants.Status200Ok);
          return;
        }
        try
        {
          //判断是否包含range请求,即文件分段下载的情况
          if (IsRangeRequest)
          {
            await SendRangeAsync();
            return;
          }
          //正常文件输出处理
          await SendAsync();
          _logger.FileServed(SubPath, PhysicalPath);
          return;
        }
        catch (FileNotFoundException)
        {
          context.Response.Clear();
        }
        await next(context);
        return;
      case PreconditionState.NotModified:
        await SendStatusAsync(Constants.Status304NotModified);
        return;
      case PreconditionState.PreconditionFailed:
        await SendStatusAsync(Constants.Status412PreconditionFailed);
        return;
      default:
        var exception = new NotImplementedException(GetPreconditionState().ToString());
        throw exception;
    }
  }

  /// <summary>
  /// 通用文件文件返回处理
  /// </summary>
  public async Task SendAsync()
  {
    SetCompressionMode();
    ApplyResponseHeaders(Constants.Status200Ok);
    string physicalPath = _fileInfo.PhysicalPath;
    var sendFile = _context.Features.Get<IHttpResponseBodyFeature>();
    //判断是否设置过输出特征操作相关,比如是否启动输出压缩,或者自定义的输出处理比如输出加密等等
    if (sendFile != null && !string.IsNullOrEmpty(physicalPath))
    {
      await sendFile.SendFileAsync(physicalPath, 0, _length, CancellationToken.None);
      return;
    }
    try
    {
      //不存在任何特殊处理的操作作,直接读取文件返回
      using (var readStream = _fileInfo.CreateReadStream())
      {
        await StreamCopyOperation.CopyToAsync(readStream, _response.Body, _length, StreamCopyBufferSize, _context.RequestAborted);
      }
    }
    catch (OperationCanceledException ex)
    {
      _context.Abort();
    }
  }

  /// <summary>
  /// 分段请求下载操作处理
  /// </summary>
  internal async Task SendRangeAsync()
  {
    if (_range == null)
    {
      ResponseHeaders.ContentRange = new ContentRangeHeaderValue(_length);
      ApplyResponseHeaders(Constants.Status416RangeNotSatisfiable);
      _logger.RangeNotSatisfiable(SubPath);
      return;
    }
    //计算range相关header数据
    ResponseHeaders.ContentRange = ComputeContentRange(_range, out var start, out var length);
    _response.ContentLength = length;
    //设置输出压缩相关header
    SetCompressionMode();
    ApplyResponseHeaders(Constants.Status206PartialContent);

    string physicalPath = _fileInfo.PhysicalPath;
    var sendFile = _context.Features.Get<IHttpResponseBodyFeature>();
    //判断是否设置过输出特征操作相关,比如是否启动输出压缩,或者自定义的输出处理比如输出加密等等
    if (sendFile != null && !string.IsNullOrEmpty(physicalPath))
    {
      _logger.SendingFileRange(_response.Headers[HeaderNames.ContentRange], physicalPath);
      await sendFile.SendFileAsync(physicalPath, start, length, CancellationToken.None);
      return;
    }
    try
    {
      using (var readStream = _fileInfo.CreateReadStream())
      {
        readStream.Seek(start, SeekOrigin.Begin); 
        _logger.CopyingFileRange(_response.Headers[HeaderNames.ContentRange], SubPath);
        //设置文件输出起始位置和读取长度
        await StreamCopyOperation.CopyToAsync(readStream, _response.Body, length, _context.RequestAborted);
      }
    }
    catch (OperationCanceledException ex)
    {
      _context.Abort();
    }
  }
}

关的读取设置和处理,其此次是针对正常返回和分段返回的情况,在返回之前判断是否有对输出做特殊处理的情况,比如输出压缩或者自定义的其他输出操作的IHttpResponseBodyFeature,分段返回和正常返回相比主要是多了一部分关于Http头Content-Range相关的设置,对于读取本身其实只是读取的起始位置和读取长度的差别。

UseDirectoryBrowser

目录浏览允许在指定目录中列出目录里的文件及子目录。出于安全方面考虑默认情况下是关闭的可以通过UseDirectoryBrowser中间件开启指定目录浏览功能。通常情况下我们会这样使用

//启用默认目录浏览,即wwwroot
app.UseDirectoryBrowser();
//或自定义指定目录浏览
var fileProvider = new PhysicalFileProvider($"{env.ContentRootPath}/MyImages");
app.UseDirectoryBrowser(new DirectoryBrowserOptions
{
  RequestPath = "/MyImages",
  FileProvider = fileProvider
});

开启之后当我们访问https://

/MyImages地址的时候将会展示如下效果,通过一个表格展示目录里的文件信息等

当然我们也可以自定义默认文件的名称,因为只要能匹配的到具体的文件既可

var defaultFilesOptions = new DefaultFilesOptions
{
  RequestPath = "/staticfiles",
  FileProvider = fileProvider
};
//我们可以清除掉系统默认的默认文件名称
defaultFilesOptions.DefaultFileNames.Clear();
defaultFilesOptions.DefaultFileNames.Add("mydefault.html");
app.UseDefaultFiles(defaultFilesOptions);

总结

    通过上面的介绍我们已经大致了解了静态文件处理的大致实现思路,相对于传统的Asp.Net程序我们可以更方便的处理静态文件信息,但是思路是一致的,IIS会优先处理静态文件,如果静态文件处理不了的情况才会交给程序去处理。ASP.NET Core也不例外,通过我们查看中间件源码里的context.GetEndpoint()==null判断可以知道,ASP.NET Core更希望我们优先去处理静态文件,而不是任意出现在其他位置去处理。关于ASP.NET Core处理静态文件的讲解就到这里,欢迎评论区探讨交流。

到此这篇关于浅谈ASP.NET Core静态文件处理源码探究的文章就介绍到这了,更多相关ASP.NET Core静态文件处理内容请搜索来客网以前的文章或继续浏览下面的相关文章希望大家以后多多支持来客网!