公司的APP其中一個功能是影片上傳及串流的服務,這陣子比較了許多影片串流及直播的通訊協定,最後選擇使用HLS協定來提供影片串流服務,流程包括「建立影片上傳API」、「影片轉檔」及「建立串流API」,這篇筆記記錄如何使用 ASP.NET Core Web API 來建立影音串流服務。

檔案結構

|-- Example
|    |--Program.cs          #程式進入點
|    |
|    |--Example.csproj      #CSharp專案檔
|    |
|    |--ApiControllers      #存放API
|    |    |--MultimediaController.cs #提供多媒體上傳及下載的API
|    |
|    |--Models              #資料表模型存放資料夾
|    |    |--Multimedia.cs  #用於存放影音資訊的模型
|    |
|    |--Services                     #服務存放資料夾
|    |    |--MultimediaService.cs    #提供多媒體服務
|    |    |--FileService.cs          #提供檔案操作服務
|    |    |--HLSService.cs           #提供HLS串流服務
|    |
|    |--FFMpeg                       #FFMpeg可執行檔(官網下載)
|
|-- Example.sln             #專案

影片上傳

建立上傳影片服務

上傳影片會用到IFormFile這個介面,IFormFile用來處理或儲存檔案,我們在Services目錄底下的MultimediaService.cs檔案裡新增CreateMultimedia函式並傳入型別為IFormFile的參數。

首先取得檔案的副檔名,同時產生一個新檔名,接著建立一個Multimedia的物件,將資訊存放進去。

這裡我們使用注入方式將資料庫注入,已建立好的Multimedia物件使用被注入的_dbContext加入到資料庫中,並且呼叫SaveChanges保存變動。

//MultimediaService.cs

public class MultimediaService
{    
    private readonly DBContext _dbContext;

    public MultimediaService(DBContext dbContext)
    {
        //注入資料庫Context,用於對資料庫進行CRUD
        _dbContext = dbContext;
    }

    public void CreateMultimedia(IFormFile file)
    {
        //取得副檔名
        var fileExt = Path.GetExtension(file.FileName);
        
        //產生一個新隨機檔名,避免檔名重複
        var fileNewName = Path.GetRandomFileName();
        
        //產生一個Multimedia物件,呼叫Add方法加入Multimedia資料表,
        //最後使用_dbContext.SaveChanges()儲存更動
        Multimedia multimedia = new Multimedia()
        {
            Uid = Guid.NewGuid(),
            Type = file.ContentType,
            Size = file.Length,
            Ext = fileExt,
            Name = fileNewName,
            RecordId = request.RecordId
        };
        var mmedia = _dbContext.Multimedia.Add(multimedia);
        _dbContext.SaveChanges();
    }
}

這個檔案先寫到這,我們先建立資料儲存及轉檔的程式,接著才會把所有服務都串起來。

除了在資料庫存放檔案資訊外,還需要在實體目錄中存放實體影音檔案提供串流及下載,我們在Services目錄底下新增FileService.cs,用於提供檔案儲存及讀取的操作。

WriteFile函式傳入要寫入到本機的檔案、檔案名稱及副檔名,檢查目的目錄是否存在,並使用CopyToAsync及Stream寫入檔案。

//FileService.cs

public class FileService
{
    //設定檔案存放位置
    private readonly string basePath = "./MultimediaFiles/";
    
    public async Task WriteFile(IFormFile file, string fileName, string fileExt)
    {
        //使用Path.Combine合併多個路徑,避免多餘的'/'符號產生
        var filePath = Path.Combine(basePath, fileName);
        
        //判斷目標目錄是否存在,不存在便創建目錄
        if (Directory.Exists(basePath) == false)
        {
            Directory.CreateDirectory(basePath);
        }
        
        //建立檔案並指定給stream
        Stream stream = File.Create(filePath + fileExt);
        
        //使用IFormFile的CopyToAsync方法,將file放入stream
        await file.CopyToAsync(stream);
        
        //關閉stream
        stream.Close();
    }
}

此時我們已經能夠將檔案寫入本機目錄中了,接著便是我們今天的重頭戲-轉檔

影片轉檔為m3u8格式

不免俗的我們再來建立一個服務

Services目錄底下新增HLSService.cs,用於提供HLS的轉檔服務。

這裡使用FFMpeg進行轉檔,使用FFMpeg進行轉檔除了要使用NuGet安裝FFMpegCorePackage之外,還要去官網下載FFMpeg的可執行檔,放入專案目錄中。

建立一個WriteFile函式,在函式內使用FFMpegArguments類別進行設定及轉檔。

FromFileInput可以用於指定輸入檔案的路徑 OutputToFile可以用於指定輸出檔案路徑 ProcessAsynchronously用於執行轉檔

//HLSService.cs

using FFMpegCore;

public class HLSService
{
    private readonly string basePath = "./MultimediaFiles/";

    public async Task WriteFile(string fileName, string fileExt)
    {
        //判斷目標目錄是否存在,不存在便創建目錄
        var filePath = Path.Combine(basePath, fileName);
        if (Directory.Exists(basePath) == false)
        {
            Directory.CreateDirectory(basePath);
        }
        await FFMpegArguments
            .FromFileInput(filePath + fileExt)//輸入路徑後方必須包含原檔的副檔名
            .OutputToFile(filePath + ".m3u8")//輸出路徑後方必須包含".m3u8"副檔名
            .ProcessAsynchronously();
    }
}

到這邊已經可以將影片檔轉為m3u8及ts檔了,接著就是要把剛才的所有步驟串起來,我們把剛剛的步驟都串到MultimediaService裡的CreateMultimedia函式裡面。

包裝上傳流程

剛才在FileService建立了WriteFile方法,用於將檔案寫入目錄中,在HLSService也建立了WriteFile方法,用於進行影片轉檔,我們接著要把這些服務串起來。

目前MultimediaService中的CreateMultimedia函式寫到新增影片資訊到資料庫,接著_dbContext.SaveChanges();的後面加入寫檔及轉檔的服務。

//MultimediaService.cs

public class MultimediaService
{    
    private readonly DBContext _dbContext;
    private readonly FileService _file;
    private readonly HLSService _hls;

    public MultimediaService(DBContext dbContext, FileService file, HLSService hls)
    {
        _dbContext = dbContext;
        _file = file;
        _hls = hls;
    }

    //注意必須使用async將此函式指定為非同步,
    //因為_file.WriteFile及_hls.WriteFile函式都是非同步方法,必須被等待
    //除了加入async以外也必須加入Task,Task代表不會回傳值且以非同步方式執行函式
    //若使用void,函式的呼叫端無法加上await,會導致呼叫端無法攔截例外
    //容易造成靈異現象
    public async Task CreateMultimedia(IFormFile file)
    {
        var fileExt = Path.GetExtension(file.FileName);
        
        var fileNewName = Path.GetRandomFileName();
        
        Multimedia multimedia = new Multimedia()
        {
            Uid = Guid.NewGuid(),
            Type = file.ContentType,
            Size = file.Length,
            Ext = fileExt,
            Name = fileNewName
        };
        var mmedia = _dbContext.Multimedia.Add(multimedia);
        _dbContext.SaveChanges();
        
        //將檔案儲存在本機
        //使用DI注入的FileService服務,呼叫WriteFile函式
        //參數傳入檔案、檔名及副檔名
        await _file.WriteFile(file, fileNewName, fileExt);

        //將檔案轉為m3u8及ts檔
        //傳入檔名及副檔名
        await _hls.WriteFile(fileNewName, fileExt);
    }
}

恭喜我們終於完成上傳了,接著只要在API中提供此服務即可

建立上傳影片API

上傳影片的API我們把他放在ApiControllers目錄中,在目錄中建立MultimediaController.cs檔案存放API

//MultimediaController.cs

[Route("api/[controller]")]
[ApiController]
public class MultimediaController : Controller
{
    private MultimediaService _mmedia;

    public MultimediaController(MultimediaService mmedia)
    {
        _mmedia = mmedia;
    }

    [HttpPost]
    public async Task<IActionResult> PostMultimedia([FromForm] IFormFile file)
    {
        if (file.File.ContentType.Contains("video") == false)
        {
            return BadRequest();
        }

        //呼叫上傳影片服務
        await _mmedia.CreateMultimedia(file);

        return Ok();
    }
}

影片上傳終於告一段落了,接著是影片下載及串流

影片串流

剛才已經把影片都轉檔好了,其實影片串流就只是開一個API讓前端丟請求過來拿檔案而已…嗎? 也沒這麼單純,我們要先從資料庫取得影片列表,我們需要裡面的檔案名稱,存取完資料庫還要開一個讀檔案的服務從目錄中讀取檔案,不過這應該比剛剛的簡單多了。

從資料庫取得影片資訊

MultimediaService.cs中加入GetMultimediaViews函式,取得_dbContextMultimedia資料表的資料。

//MultimediaService.cs

public class MultimediaService
{    
    private readonly DBContext _dbContext;
    private readonly FileService _file;
    private readonly HLSService _hls;

    public MultimediaService(DBContext dbContext, FileService file, HLSService hls)
    {
        _dbContext = dbContext;
        _file = file;
        _hls = hls;
    }
    
    public List<Multimedia> GetMultimediaViews()
    {
        return _dbContext.Multimedia.ToList();
    }
    
    public async Task CreateMultimedia(IFormFile file)
    {
        ...
    }
}

開好服務後由MultimediaController呼叫GetMultimediaViews服務,將資料表中的影音資訊清單返回給使用者

//MultimediaController.cs

[Route("api/[controller]")]
[ApiController]
public class MultimediaController : Controller
{
    private MultimediaService _mmedia;

    public MultimediaController(MultimediaService mmedia)
    {
        _mmedia = mmedia;
    }
    
    [HttpGet]
    [Route("List")]
    public IActionResult GetMultimediaList()
    {

        List<Multimedia> list = _mmedia.GetMultimediaViews();

        return Ok(list);
    }

    [HttpPost]
    public async Task<IActionResult> PostMultimedia([FromForm] IFormFile file)
    {
        ...
    }
}

從目錄取得檔案

先前寫入檔案時把程式寫在FileService.cs,讀取檔案我們一樣把函式放在這個檔案裡面

//FileService.cs

public class FileService
{

    private readonly string basePath = "./MultimediaFiles/";


    public Stream? ReadFile(string fileName)
    {
        string filePath = Path.Combine(basePath, fileName);
        if (File.Exists(fileFullPath) == false)
        {
            return null;
        }

        //使用FileStream讀取並返回檔案Stream
        return new FileStream(filePath, FileMode.Open, FileAccess.Read,
            FileShare.ReadWrite, 4096, FileOptions.SequentialScan);
    }
    
    public async Task WriteFile(IFormFile file, string fileName, string fileExt)
    {
        ...
    }
    
}

接著在MultimediaService中呼叫剛剛建立的讀取檔案函式,建立GetMultimedia服務

//MultimediaService.cs

public class MultimediaService
{    
    private readonly DBContext _dbContext;
    private readonly FileService _file;
    private readonly HLSService _hls;

    public MultimediaService(DBContext dbContext, FileService file, HLSService hls)
    {
        _dbContext = dbContext;
        _file = file;
        _hls = hls;
    }

    public Stream GetMultimedia(string filename)
    {
        return _file.ReadFile(filename);
    }
    
    public List<Multimedia> GetMultimediaViews()
    {
        ...
    }
    
    public async Task CreateMultimedia(IFormFile file)
    {
        ...
    }
}

到這邊已經完成所有服務了,接著只要建立取得檔案的API即可

建立串流API

MultimediaController中建立GetHLSMultimediaAPI函式,函式中呼叫MultimediaService中的GetHLSMultimedia方法,取得stream後使用File函式將stream返回給使用者,並指定Content-Type為video/mp2t

//MultimediaController.cs

[Route("api/[controller]")]
[ApiController]
public class MultimediaController : Controller
{
    private MultimediaService _mmedia;

    public MultimediaController(MultimediaService mmedia)
    {
        _mmedia = mmedia;
    }
    
    [HttpGet]
    [Route("List")]
    public IActionResult GetMultimediaList()
    {
        ...
    }
    
    [HttpGet]
    [Route("HLS/{FileName}")]
    public async Task<IActionResult> GetHLSMultimedia(string FileName)
    {
        Stream? stream = await _mmedia.GetHLSMultimedia(FileName);
        if (stream == null)
        {
            return NotFound();
        }

        return File(stream, "video/mp2t");
    }

    [HttpPost]
    public async Task<IActionResult> PostMultimedia([FromForm] IFormFile file)
    {
        ...
    }
}

呼叫API

由於我們建立的是HttpGet方法,在瀏覽器網址列可以直接輸入網址呼叫API,在MultimediaController設定的路由是/api/Multimedia/HLS/<已轉檔後的檔名>,以檔名為vxvi0clz.4od的檔案為例,剛剛在轉檔的時候會轉出一個vxvi0clz.4od.m3u8檔案及vxvi0clz.4od0.ts,vxvi0clz.4od1.ts…等多個ts檔案,存取時呼叫/api/Multimedia/HLS/vxvi0clz.4od.m3u8即可取得檔案。

參考資料