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

ASP.NET Web API 中的 HTTP 206 部分内容 - 视频文件流

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.95/5 (26投票s)

2014年10月3日

CPOL

5分钟阅读

viewsIcon

164240

downloadIcon

4599

实现 HTTP 206 Partial Content 的分步演练

目录

引言

本文重点介绍 ASP.NET Web API 中 HTTP 206 Partial Content 的实现。我想描述我如何使用 ApiController 进行实现,并处理一些潜在的性能问题。我们的目标是创建一个视频文件流服务和一个播放视频的 HTML5 页面。

背景

在我上一篇文章中,我们讨论了 HTTP 206 的特性及其相关标头。此外,我们还展示了 Node.js 和 HTML5 中的视频流。这次,我们将转向 ASP.NET Web API,并对我们的实现进行一些讨论。如果您想了解此 HTTP 状态码的更多详细信息,上一篇文章是一个很好的参考。本文将不再重复。

必备组件

开始实现

首先,我们期望视频流的 URL 如下所示

https:///movie/api/media/play?f=praise-our-lord.mp4

其中 movie 是我们在 IIS 中的应用程序名称,media 是控制器名称,play 是其操作名称,参数 f 代表我们要播放的视频文件。

基于此 URL,我们将从 Movie.Controllers 命名空间下的 MediaController 开始,该类派生自 ApiController。在我们开始实际操作之前,需要几个 static 字段和方法来帮助我们进行接下来的步骤。

using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Net.Mime;
using System.Web.Configuration;
using System.Web.Http;

namespace Movie.Controllers
{
    public class MediaController : ApiController
    {
        #region Fields

        // This will be used in copying input stream to output stream.
        public const int ReadStreamBufferSize = 1024 * 1024;
        // We have a read-only dictionary for mapping file extensions and MIME names. 
        public static readonly IReadOnlyDictionary<string, string> MimeNames;
        // We will discuss this later.
        public static readonly IReadOnlyCollection<char> InvalidFileNameChars;
        // Where are your videos located at? Change the value to any folder you want.
        public static readonly string InitialDirectory;

        #endregion

        #region Constructors

        static MediaController()
        {
            var mimeNames = new Dictionary<string, string>();
			
            mimeNames.Add(".mp3", "audio/mpeg");    // List all supported media types; 
            mimeNames.Add(".mp4", "video/mp4");
            mimeNames.Add(".ogg", "application/ogg");
            mimeNames.Add(".ogv", "video/ogg");
            mimeNames.Add(".oga", "audio/ogg");
            mimeNames.Add(".wav", "audio/x-wav");
            mimeNames.Add(".webm", "video/webm");

            MimeNames = new ReadOnlyDictionary<string, string>(mimeNames);

            InvalidFileNameChars = Array.AsReadOnly(Path.GetInvalidFileNameChars());
            InitialDirectory = WebConfigurationManager.AppSettings["InitialDirectory"];
        }

        #endregion

        #region Actions

        // Later we will do something around here.

        #endregion

        #region Others

        private static bool AnyInvalidFileNameChars(string fileName)
        {
            return InvalidFileNameChars.Intersect(fileName).Any(); 
        }

        private static MediaTypeHeaderValue GetMimeNameFromExt(string ext)
        {
            string value;

            if (MimeNames.TryGetValue(ext.ToLowerInvariant(), out value))
                return new MediaTypeHeaderValue(value);
            else
                return new MediaTypeHeaderValue(MediaTypeNames.Application.Octet);
        }

        private static bool TryReadRangeItem(RangeItemHeaderValue range, long contentLength, 
            out long start, out long end)
        {
            if (range.From != null)
            {
                start = range.From.Value;
                if (range.To != null)
                    end = range.To.Value;
                else
                    end = contentLength - 1;
            }
            else
            {
                end = contentLength - 1;
                if (range.To != null)
                    start = contentLength - range.To.Value;
                else
                    start = 0;
            }
            return (start < contentLength && end < contentLength);
        }

        private static void CreatePartialContent(Stream inputStream, Stream outputStream,
            long start, long end)
        {
            int count = 0;
            long remainingBytes = end - start + 1;
            long position = start;
            byte[] buffer = new byte[ReadStreamBufferSize];
            
            inputStream.Position = start;
            do
            {
                try
                {
                    if (remainingBytes > ReadStreamBufferSize)
                        count = inputStream.Read(buffer, 0, ReadStreamBufferSize);
                    else
                        count = inputStream.Read(buffer, 0, (int)remainingBytes); 
                    outputStream.Write(buffer, 0, count);
                }
                catch (Exception error)
                {
                    Debug.WriteLine(error);
                    break;
                }
                position = inputStream.Position;
                remainingBytes = end - position + 1;
            } while (position <= end);
        }
         
        #endregion
    } 
}

我们有

  • AnyInvalidFileNameChars() 帮助我们检查 URL 参数 f 中是否有任何无效的文件名字符(顺便说一句,这是使用 LINQ 操作 string 的一个很好的例子)。这可以防止一些不必要的文件系统访问,因为无效文件名文件根本不存在。
  • GetMimeNameFromExt() 帮助我们从只读字典 MimeNames 获取对应的 Content-Type 标头值,并带有文件扩展名。如果找不到值,则默认为 application/oct-stream
  • TryReadRangeItem() 帮助我们从当前 HTTP 请求中读取 Range 标头。返回的布尔值表示范围是否可用。如果开始或结束位置大于文件长度(参数 contentLength),则返回 false
  • CreatePartialContent() 帮助我们使用指定的范围将文件流的内容复制到响应流。

有了这些工具,实现 Play() 方法的操作将更加容易。其原型是

[HttpGet]
public HttpResponseMessage Play(string f) { }

其中参数 f 代表 URL 参数 fHttpGetAttribute 声明 GET 是唯一可接受的 HTTP 方法。响应标头和内容在 HttpResponseMessage 类中发送。此方法背后的逻辑流程可以用以下图表描述。

自然,我们的第一项工作是检查文件是否存在。如果不存在,将导致 HTTP 404 Not Found 状态。接下来是检查当前请求中是否包含 Range 标头。如果不包含,请求将被视为普通请求,并导致 HTTP 200 OK 状态。第三步是确定是否可以根据目标文件满足 Range 标头。如果范围不在文件长度之内,将向浏览器响应 HTTP 416 Requested Range Not Satisfiable 状态。在执行完这些步骤后,最后一步是传输具有指定范围的目标文件,故事以 HTTP 206 Partial Content 状态结束。

这是 Play() 操作的完整代码。

[HttpGet]
public HttpResponseMessage Play(string f)
{
    // This can prevent some unnecessary accesses. 
    // These kind of file names won't be existing at all. 
    if (string.IsNullOrWhiteSpace(f) || AnyInvalidFileNameChars(f))
        throw new HttpResponseException(HttpStatusCode.NotFound);

    FileInfo fileInfo = new FileInfo(Path.Combine(InitialDirectory, f));
    
    if (!fileInfo.Exists)
        throw new HttpResponseException(HttpStatusCode.NotFound);

    long totalLength = fileInfo.Length;

    RangeHeaderValue rangeHeader = base.Request.Headers.Range;
    HttpResponseMessage response = new HttpResponseMessage();

    response.Headers.AcceptRanges.Add("bytes");

    // The request will be treated as normal request if there is no Range header.
    if (rangeHeader == null || !rangeHeader.Ranges.Any())
    {
        response.StatusCode = HttpStatusCode.OK;
        response.Content = new PushStreamContent((outputStream, httpContent, transpContext)
		=>
            {
                using (outputStream) // Copy the file to output stream straightforward. 
                using (Stream inputStream = fileInfo.OpenRead())
                {
                    try
                    {
                        inputStream.CopyTo(outputStream, ReadStreamBufferSize);
                    }
                    catch (Exception error)
                    {
                        Debug.WriteLine(error);
                    }
                }
            }, GetMimeNameFromExt(fileInfo.Extension));

        response.Content.Headers.ContentLength = totalLength;
        return response;
    }

    long start = 0, end = 0;

    // 1. If the unit is not 'bytes'.
    // 2. If there are multiple ranges in header value.
    // 3. If start or end position is greater than file length.
    if (rangeHeader.Unit != "bytes" || rangeHeader.Ranges.Count > 1 ||
        !TryReadRangeItem(rangeHeader.Ranges.First(), totalLength, out start, out end))
    {
        response.StatusCode = HttpStatusCode.RequestedRangeNotSatisfiable;
        response.Content = new StreamContent(Stream.Null);  // No content for this status.
        response.Content.Headers.ContentRange = new ContentRangeHeaderValue(totalLength);
        response.Content.Headers.ContentType = GetMimeNameFromExt(fileInfo.Extension);

        return response;
    } 

    var contentRange = new ContentRangeHeaderValue(start, end, totalLength);

    // We are now ready to produce partial content.
    response.StatusCode = HttpStatusCode.PartialContent;
    response.Content = new PushStreamContent((outputStream, httpContent, transpContext)
	=>
        {
            using (outputStream) // Copy the file to output stream in indicated range.
            using (Stream inputStream = fileInfo.OpenRead())
                CreatePartialContent(inputStream, outputStream, start, end);

        }, GetMimeNameFromExt(fileInfo.Extension));

    response.Content.Headers.ContentLength = end - start + 1;
    response.Content.Headers.ContentRange = contentRange;

    return response;
}

播放视频

现在是时候播放视频了。我们有一个简单的 HTML5 页面,其中包含一个 <video /> 元素和一个 <source /> 元素,它引用了我们之前提到的 URL。

<!DOCTYPE html>
<html>
    <head>
        <script type="text/javascript">

            function onLoad() {
                var sec = parseInt(document.location.search.substr(1));
                
                if (!isNaN(sec))
                    mainPlayer.currentTime = sec;
            }
        
        </script>
        <title>Partial Content Demonstration</title>
    </head>
    <body>
        <h3>Partial Content Demonstration</h3>
        <hr />
        <video id="mainPlayer" width="640" height="360" 
            autoplay="autoplay" controls="controls" onloadeddata="onLoad()">
            <source src="api/media/play?f=praise-our-lord.mp4" />
        </video>
    </body>
</html>

如您所见,onLoad() 函数允许我们通过添加参数跳到指定的秒数。如果省略参数,<video /> 元素将从零秒开始播放视频。例如,如果我们想从第 120 秒开始观看视频,那么

https:///movie/index.html?120

让我们在 Chrome 中尝试此 URL。

然后我们按 F12 打开开发人员工具,切换到 Network 标签,查看后台发生了什么。

这些标头几乎解释了一切。一旦 onLoad() 函数被触发,播放器会发送一个请求,其中包含一个 Range 标头,起始位置正好等于视频中第 120 秒的字节位置。响应标头 Content-Range 描述了开始和结束位置以及可用的总字节数。此示例展示了部分内容机制的最大优势:当视频或音频文件过长时,观看者可以跳到他们想要的任何秒。

性能考量

您可能已经注意到,我们在 Play() 操作中使用了 PushStreamContent 而不是 StreamContent(空内容除外)来传输文件流。它们都属于 System.Net.Http 命名空间,并派生自 HttpContent 类。它们之间的差异通常可以总结为以下几点。

PushStreamContent 与 StreamContent

  • 顺序 - 对于 StreamContent,您必须在操作结束之前生成内容流。对于 PushStreamContent,您将在退出操作**之后**生成它。
  • 文件访问 - 对于 StreamContent,您在浏览器开始接收之前从文件生成内容流。对于 PushStreamContent,您将在浏览器接收到所有 HTTP 标头并准备好呈现内容**之后**执行此操作,这意味着如果浏览器只接收到标头但取消了内容呈现,则文件将**不会**被打开。
  • 内存使用 - 对于 StreamContent,您必须在操作结束之前从文件生成部分内容流,这意味着它将在浏览器接收完所有字节之前暂时保留在内存中。对于 PushStreamContent,您可以直接从文件复制内容到输出流,并指定范围,而无需暂时将内容保留在内存中。

因此,我们选择 PushStreamContent 进行视频文件流。它可以减少内存使用并更高效地工作。

历史

2014-11-25

  • 当文件大小大于 Int32.MaxValue 时,修复了 CreatePartialContent() 方法中一个潜在的问题。
  • Play() 方法中添加了 try-catch 部分,以防止在调试模式下出现不必要的异常消息。
  • 增加了 ReadStreamBufferSize 值以提高性能。
© . All rights reserved.