yzk学英语项目-转码服务

125 阅读6分钟

每一条转码任务就是一个EncodingItem实体,开始时间,当前状态(枚举类型:创建完成、开始处理、成功、失败)、日志文本(方便排查问题)。启动任务,把任务标记为开始,发布一个领域事件。同理增加完成、失败这些方法。然后创建一个报表数据处理的接口和实现类。

把转码项别的表放在关系型数据库中。

在托管服务中,找到准备转码的任务,一条条转或者多条同时并行处理,如果仍然比较复杂,可以部署10个转码服务器,每个拽一个转码任务来转,为了避免两个转码服务器同时拽一个任务处理,使用redis来实现分布式锁,redLock。(跨服务器的,这个服务器锁上,那个服务器也不能访问资源)。抢到一个锁,就开始处理,发出一个集成事件。

下载源文件,计算散列值,查看是否已经转码过,转码完成就返回。

把转码任务状态变化的领域事件转换成集成事件发出去。

没有领域服务

功能

转码服务用于把其他音频格式转换为M4A格式。考虑到以后系统中可能会有视频、文档等其他的格式的转码需求,因此转码服务要设计的扩展性比较强,方便我们为它增加更多的格式转换能力。

领域层

实体类

实体类

服务处理的每一条任务对应一个EncodingItem实体。

//代表一条转码任务
public record EncodingItem : BaseEntity, IAggregateRoot, IHasCreationTime
    {
    	//创建时间
        public DateTime CreationTime { get; private set; }
    	//任务是从什么系统发过来的(目前只有听力服务)
        public string SourceSystem { get; private set; }


        /// <summary>
        /// 文件大小(尺寸为字节)
        /// </summary>
        public long? FileSizeInBytes { get; private set; }
        /// <summary>
        /// 文件名字(非全路径)
        /// </summary>
        public string Name { get; private set; }

        /// <summary>
        /// 两个文件的大小和散列值(SHA256)都相同的概率非常小。因此只要大小和SHA256相同,就认为是相同的文件。
        /// SHA256的碰撞的概率比MD5低很多。
        /// </summary>
        public string? FileSHA256Hash { get; private set; }

        /// <summary>
        /// 待转码的原文件(不可空)
        /// </summary>
        public Uri SourceUrl { get; private set; }


        /// <summary>
        /// 转码完成的路径(可空,转码完成前是空的)
        /// </summary>
        public Uri? OutputUrl { get; private set; }

        /// <summary>
        /// 转码的目标格式,比如m4a、mp4等
        /// </summary>
        public string OutputFormat { get; private set; }
    	//当前转码状态
        public ItemStatus Status { get; private set; }

        /// <summary>
        /// 转码工具的输出日志(方便排查问题)
        /// </summary>
        public string? LogText { get; private set; }

    	//启动任务(把任务状态标注为开始,发布一个任务启动的领域事件)
        public void Start()
        {
            this.Status = ItemStatus.Started;
            AddDomainEvent(new EncodingItemStartedEvent(Id, SourceSystem));
        }

        public void Complete(Uri outputUrl)
        {
            this.Status = ItemStatus.Completed;
            this.OutputUrl = outputUrl;
            this.LogText = "转码成功";
            AddDomainEvent(new EncodingItemCompletedEvent(Id, SourceSystem, outputUrl));
        }

        public void Fail(string logText)
        {
            //todo:通过集成事件写入Logging系统
            this.Status = ItemStatus.Failed;
            this.LogText = logText;
            AddDomainEventIfAbsent(new EncodingItemFailedEvent(Id, SourceSystem, logText));
        }

        public void Fail(Exception ex)
        {
            Fail($"转码处理失败:{ex}");
        }

        public void ChangeFileMeta(long fileSize, string hash)
        {
            this.FileSizeInBytes = fileSize;
            this.FileSHA256Hash = hash;
        }

        public static EncodingItem Create(Guid id, string name, Uri sourceUrl, string outputFormat, string sourceSystem)
        {
            EncodingItem item = new EncodingItem()
            {
                Id = id,
                CreationTime = DateTime.Now,
                Name = name,
                OutputFormat = outputFormat,
                SourceUrl = sourceUrl,
                Status = ItemStatus.Ready,
                SourceSystem = sourceSystem,
            };
            item.AddDomainEvent(new EncodingItemCreatedEvent(item));
            return item;
        }
    }

转码状态枚举

//代表任务的当前转码状态
public enum ItemStatus
    {
        Ready,//任务刚创建完成
        Started,//开始处理
        Completed,//成功
        Failed,//失败
    }

仓储接口

IMediaEncoderRepository是领域的仓储接口

public interface IMediaEncoderRepository
    {
        Task<EncodingItem?> FindCompletedOneAsync(string fileHash, long fileSize);
        Task<EncodingItem[]> FindAsync(ItemStatus status);
    }

防腐层

转码相关

IMediaEncoder是转码器的接口。MediaEncoderFactory类用来加载所有转码器,并且创建合适的转码器

public interface IMediaEncoder
    {
        /// <summary>
        /// 是否能处理目标为outputFormat类型的文件
        /// </summary>
        /// <param name="outputFormat"></param>
        /// <returns></returns>
        bool Accept(string outputFormat);

        /// <summary>
        /// 进行转换
        /// </summary>
        /// <param name="sourceFile"></param>
        /// <param name="destFile"></param>
        /// <param name="destFormat"></param>
        /// <param name="args"></param>
        /// <returns></returns>
        Task EncodeAsync(FileInfo sourceFile, FileInfo destFile, string destFormat, string[]? args, CancellationToken ct);
    }

工厂类,创建一个转码器。工厂类需要注册

//注入所有解码器对象,Create方法返回合适的解码器
public class MediaEncoderFactory
    {
        private readonly IEnumerable<IMediaEncoder> encoders;
        public MediaEncoderFactory(IEnumerable<IMediaEncoder> encoders)
        {
            this.encoders = encoders;
        }

        public IMediaEncoder? Create(string outputFormat)
        {
            foreach (var encoder in encoders)
            {
                if (encoder.Accept(outputFormat))
                {
                    return encoder;
                }
            }
            return null;
        }
    }

模块自注册

public class ModuleInitializer : IModuleInitializer
{
    public void Initialize(IServiceCollection services)
    {
        services.AddScoped<MediaEncoderFactory>();
    }
}

领域事件

//任务完成
public record EncodingItemCompletedEvent(Guid Id, string SourceSystem, Uri OutputUrl) : INotification;

public record EncodingItemCreatedEvent(EncodingItem Value) : INotification;

public record EncodingItemFailedEvent(Guid Id, string SourceSystem, string ErrorMessage) : INotification;

public record EncodingItemStartedEvent(Guid Id, string SourceSystem) : INotification;

基础设施层

奇妙之处,调用了一个exe文件类完成转码任务

使用FFMpeg完成音频文件转码。把ffmpeg.exe放到项目的根目录,并且设定这个文件的【复制到输出目标】为“如果较新则复制”。

实体配置类

class EncodingItemConfig : IEntityTypeConfiguration<EncodingItem>
    {
        public void Configure(EntityTypeBuilder<EncodingItem> builder)
        {
            builder.ToTable("T_ME_EncodingItems");
            //todo:id需要非聚集索引。
            //todo:符合索引。
            builder.Property(e => e.Name).HasMaxLength(256);
            builder.Property(e => e.FileSHA256Hash).HasMaxLength(64).IsUnicode(false);
            builder.Property(e => e.OutputFormat).HasMaxLength(10).IsUnicode(false);
            builder.Property(e => e.Status).HasConversion<string>().HasMaxLength(10);
        }
    }

仓储服务实现

class MediaEncoderRepository : IMediaEncoderRepository
    {
        private readonly MEDbContext dbContext;

        public MediaEncoderRepository(MEDbContext dbContext)
        {
            this.dbContext = dbContext;
        }
    	//获得某一种状态所有的任务
        public Task<EncodingItem[]> FindAsync(ItemStatus status)
        {
            return dbContext.EncodingItems.Where(e => e.Status == ItemStatus.Ready)
                .ToArrayAsync();
        }
    	//获得指定文件大小、类型、状态已完成的转码任务
        public Task<EncodingItem?> FindCompletedOneAsync(string fileHash, long fileSize)
        {
            return dbContext.EncodingItems.FirstOrDefaultAsync(e => e.FileSHA256Hash == fileHash
                    && e.FileSizeInBytes == fileSize && e.Status == ItemStatus.Completed);
        }
    }

防腐层实现

FFMPEG用来转码,这是个非常流行的转码工具,进行音频、视频的转码,可以用命令行启动(比较麻烦)。这里采用第三方库FFmpeg.NET,使得用命令行更加简单,通过Engine对象的ConvertAsync方法进行转码

//Nuget

FFmpeg.NET

public class ToM4AEncoder : IMediaEncoder
{
    public bool Accept(string outputFormat)
    {
        return "m4a".Equals(outputFormat, StringComparison.OrdinalIgnoreCase);
    }

    public async Task EncodeAsync(FileInfo sourceFile, FileInfo destFile, string destFormat, string[]? args, CancellationToken ct)
    {
        //可以用“FFmpeg.AutoGen”,因为他是bingding库,不用启动独立的进程,更靠谱。但是编程难度大,这里重点不是FFMPEG,所以先用命令行实现
        var inputFile = new InputFile(sourceFile);
        var outputFile = new OutputFile(destFile);
        string baseDir = AppContext.BaseDirectory;//程序的运行根目录
        string ffmpegPath = Path.Combine(baseDir, "ffmpeg.exe");
        var ffmpeg = new Engine(ffmpegPath);
        string? errorMsg = null;
        ffmpeg.Error += (s, e) =>
        {
            errorMsg = e.Exception.Message;
        };
        await ffmpeg.ConvertAsync(inputFile, outputFile, ct);//进行转码
        if (errorMsg != null)
        {
            throw new Exception(errorMsg);
        }
    }
}

模块自注册

public class ModuleInitializer : IModuleInitializer
{
    public void Initialize(IServiceCollection services)
    {
        services.AddScoped<IMediaEncoderRepository, MediaEncoderRepository>();
        services.AddScoped<IMediaEncoder, ToM4AEncoder>();
    }
}

DbContext

public class MEDbContext : BaseDbContext
    {
        public DbSet<EncodingItem> EncodingItems { get; private set; }

        public MEDbContext(DbContextOptions<MEDbContext> options, IMediator mediator)
            : base(options, mediator)
        {
        }

        protected override void OnModelCreating(ModelBuilder modelBuilder)
        {
            base.OnModelCreating(modelBuilder);
            modelBuilder.ApplyConfigurationsFromAssembly(this.GetType().Assembly);
            modelBuilder.EnableSoftDeletionGlobalFilter();
        }
    }

应用层

后台服务

完成转码任务的最主要的类就是后台服务EncodingBgService,负责定时扫描还没有完成的转码任务,拿到转码任务并开始转码。

这个后台任务每隔五秒扫描一次数据库中是否有待转码任务,如果有的话,则调用ProcessItemAsync进行处理。由于转码是一个消耗资源的操作,因此即使有多条待转码任务,我们也是逐条处理,如果服务器的性能比较好,我们也可以设定允许多个转码任务并行执行。

当转码服务比较多时,可以部署多个转码微服务,但是为了避免两个转码服务器处理同一个转码任务的问题,我们采用Redis提供的RedLock分布式锁来确保一个任务只能被一台转码服务器处理。

  • DownloadSrcAsync:下载原文件
  • BuildDestFileInfo:构建转码后的目标文件,把文件名拼出来
  • EncodeAsync:转码的方法,拿到一个转码器对象,开始转码,转码成功后将文件上传
  • UploadFileAsync:把转码后的文件上传到云存储服务器
  • ProcessItemAsync:主逻辑。给资源上锁,下载原文件,计算源文件的散列值,在数据库中查一下是否存在,如果存在则告诉完成了。如果不存在则开始转码
public class EncodingBgService : BackgroundService
{
    private readonly MEDbContext dbContext;
    private readonly IMediaEncoderRepository repository;
	//注入redis(RedLockMultiplexer)
    private readonly List<RedLockMultiplexer> redLockMultiplexerList;
    private readonly ILogger<EncodingBgService> logger;
    private readonly IHttpClientFactory httpClientFactory;
    private readonly MediaEncoderFactory encoderFactory;
    private readonly IOptionsSnapshot<FileServiceOptions> optionFileService;
    private readonly IServiceScope serviceScope;
    private readonly IEventBus eventBus;
    private readonly IOptionsSnapshot<JWTOptions> optionJWT;
    private readonly ITokenService tokenService;

    public EncodingBgService(IServiceScopeFactory spf)
    {
        //MEDbContext等是Scoped,而BackgroundService是Singleton,所以不能直接注入,需要手动开启一个新的Scope
        this.serviceScope = spf.CreateScope();
        var sp = serviceScope.ServiceProvider;
        this.dbContext = sp.GetRequiredService<MEDbContext>(); ;
        //生产环境中,RedLock需要五台服务器才能体现价值,测试环境无所谓
        IConnectionMultiplexer connectionMultiplexer = sp.GetRequiredService<IConnectionMultiplexer>();
        this.redLockMultiplexerList = new List<RedLockMultiplexer> { new RedLockMultiplexer(connectionMultiplexer) };
        this.logger = sp.GetRequiredService<ILogger<EncodingBgService>>();
        this.httpClientFactory = sp.GetRequiredService<IHttpClientFactory>();
        this.encoderFactory = sp.GetRequiredService<MediaEncoderFactory>();
        this.optionFileService = sp.GetRequiredService<IOptionsSnapshot<FileServiceOptions>>();
        this.eventBus = sp.GetRequiredService<IEventBus>();
        this.optionJWT = sp.GetRequiredService<IOptionsSnapshot<JWTOptions>>();
        this.tokenService = sp.GetRequiredService<ITokenService>();
        this.repository = sp.GetRequiredService<IMediaEncoderRepository>();
    }

    /// <summary>
    /// 下载原文件
    /// </summary>
    /// <param name="encItem"></param>
    /// <param name="ct"></param>
    /// <returns>ok表示是否下载成功,sourceFile为保存成功的本地文件</returns>
    private async Task<(bool ok, FileInfo sourceFile)> DownloadSrcAsync(EncodingItem encItem, CancellationToken ct)
    {
        //开始下载源文件
        string tempDir = Path.Combine(Path.GetTempPath(), "MediaEncodingDir");
        //源文件的临时保存路径
        string sourceFullPath = Path.Combine(tempDir, Guid.NewGuid() + "."
            + Path.GetExtension(encItem.Name));
        FileInfo sourceFile = new FileInfo(sourceFullPath);
        Guid id = encItem.Id;
        sourceFile.Directory!.Create();//创建可能不存在的文件夹
        logger.LogInterpolatedInformation($"Id={id},准备从{encItem.SourceUrl}下载到{sourceFullPath}");
        HttpClient httpClient = httpClientFactory.CreateClient();
        var statusCode = await httpClient.DownloadFileAsync(encItem.SourceUrl, sourceFullPath, ct);
        if (statusCode != HttpStatusCode.OK)
        {
            logger.LogInterpolatedWarning($"下载Id={id},Url={encItem.SourceUrl}失败,{statusCode}");
            sourceFile.Delete();
            return (false, sourceFile);
        }
        else
        {
            return (true, sourceFile);
        }
    }
    /// <summary>
    /// 把转码后的文件上传到云存储服务器
    /// </summary>
    /// <param name="file"></param>
    /// <param name="ct"></param>
    /// <returns>保存后的远程文件的路径</returns>
    private Task<Uri> UploadFileAsync(FileInfo file, CancellationToken ct)
    {
        Uri urlRoot = optionFileService.Value.UrlRoot;
        FileServiceClient fileService = new FileServiceClient(httpClientFactory,
                urlRoot, optionJWT.Value, tokenService);
        return fileService.UploadAsync(file, ct);
    }

    /// <summary>
    /// 构建转码后的目标文件,把文件名拼出来
    /// </summary>
    /// <param name="encItem"></param>
    /// <returns></returns>
    private static FileInfo BuildDestFileInfo(EncodingItem encItem)
    {
        string outputFormat = encItem.OutputFormat;
        string tempDir = Path.GetTempPath();
        string destFullPath = Path.Combine(tempDir, Guid.NewGuid() + "." + outputFormat);
        return new FileInfo(destFullPath);
    }

    /// <summary>
    /// 计算文件的散列值
    /// </summary>
    /// <param name="file"></param>
    /// <returns></returns>
    private static string ComputeSha256Hash(FileInfo file)
    {
        using (FileStream streamSrc = file.OpenRead())
        {
            return HashHelper.ComputeSha256Hash(streamSrc);
        }
    }

    /// <summary>
    /// 对srcFile按照outputFormat格式转码,保存到outputFormat
	/// 转码的方法,拿到一个转码器对象,开始转码,转码成功后将文件上传
    /// </summary>
    /// <param name="srcFile"></param>
    /// <param name="destFile"></param>
    /// <param name="outputFormat"></param>
    /// <param name="ct"></param>
    /// <returns>转码结果</returns>
    private async Task<bool> EncodeAsync(FileInfo srcFile, FileInfo destFile,
        string outputFormat, CancellationToken ct)
    {
        var encoder = encoderFactory.Create(outputFormat);
        if (encoder == null)
        {
            logger.LogInterpolatedError($"转码失败,找不到转码器,目标格式:{outputFormat}");
            return false;
        }
        try
        {
            await encoder.EncodeAsync(srcFile, destFile, outputFormat, null, ct);
        }
        catch (Exception ex)
        {
            logger.LogInterpolatedError($"转码失败", ex);
            return false;
        }
        return true;
    }

    private async Task ProcessItemAsync(EncodingItem encItem, CancellationToken ct)
    {
        Guid id = encItem.Id;
        var expiry = TimeSpan.FromSeconds(30);
        //Redis分布式锁来避免两个转码服务器处理同一个转码任务的问题
        var redlockFactory = RedLockFactory.Create(redLockMultiplexerList);
        string lockKey = $"MediaEncoder.EncodingItem.{id}";
        //用RedLock分布式锁,锁定对EncodingItem的访问
        using var redLock = await redlockFactory.CreateLockAsync(lockKey, expiry);
        if (!redLock.IsAcquired)
        {
            logger.LogInterpolatedWarning($"获取{lockKey}锁失败,已被抢走");
            //获得锁失败,锁已经被别人抢走了,说明这个任务被别的实例处理了(有可能有服务器集群来分担转码压力)
            return;//再去抢下一个
        }
        encItem.Start();
        await dbContext.SaveChangesAsync(ct);//立即保存一下状态的修改
                                             //发出一次集成事件
        (var downloadOk, var srcFile) = await DownloadSrcAsync(encItem, ct);
        if (!downloadOk)
        {
            encItem.Fail($"下载失败");
            return;
        }
        FileInfo destFile = BuildDestFileInfo(encItem);
        try
        {
            logger.LogInterpolatedInformation($"下载Id={id}成功,开始计算Hash值");
            long fileSize = srcFile.Length;
            string srcFileHash = ComputeSha256Hash(srcFile);
            //如果之前存在过和这个文件大小、hash一样的文件,就认为重复了
            var prevInstance = await repository.FindCompletedOneAsync(srcFileHash, fileSize);
            if (prevInstance != null)
            {
                logger.LogInterpolatedInformation($"检查Id={id}Hash值成功,发现已经存在相同大小和Hash值的旧任务Id={prevInstance.Id},返回!");
                eventBus.Publish("MediaEncoding.Duplicated", new { encItem.Id, encItem.SourceSystem, OutputUrl = prevInstance.OutputUrl });
                encItem.Complete(prevInstance.OutputUrl!);
                return;
            }
            //开始转码
            logger.LogInterpolatedInformation($"Id={id}开始转码,源路径:{srcFile},目标路径:{destFile}");
            string outputFormat = encItem.OutputFormat;
            var encodingOK = await EncodeAsync(srcFile, destFile, outputFormat, ct); ;
            if (!encodingOK)
            {
                encItem.Fail($"转码失败");
                return;
            }
            //开始上传
            logger.LogInterpolatedInformation($"Id={id}转码成功,开始准备上传");
            Uri destUrl = await UploadFileAsync(destFile, ct);
            encItem.Complete(destUrl);
            encItem.ChangeFileMeta(fileSize, srcFileHash);
            logger.LogInterpolatedInformation($"Id={id}转码结果上传成功");
            //发出集成事件和领域事件
        }
        finally
        {
            srcFile.Delete();
            destFile.Delete();
        }
    }
	//死循环,先找到所有处于ready状态的转码任务,挨个调用ProcessItemAsync进行转码,因为转码比较消耗cpu等资源,因此不采用并行转码。转完一圈休息5s钟
    protected override async Task ExecuteAsync(CancellationToken ct = default)
    {
        while (!ct.IsCancellationRequested)
        {
            //获取所有处于Ready状态的任务
            //ToListAsync()可以避免在循环中再用DbContext去查询数据导致的“There is already an open DataReader associated with this Connection which must be closed first.”
            var readyItems = await repository.FindAsync(ItemStatus.Ready);
            foreach (EncodingItem readyItem in readyItems)
            {
                try
                {
                    await ProcessItemAsync(readyItem, ct);//因为转码比较消耗cpu等资源,因此串行转码
                }
                catch (Exception ex)
                {
                    readyItem.Fail(ex);
                }
                await this.dbContext.SaveChangesAsync(ct);
            }
            await Task.Delay(5000);//暂停5s,避免没有任务的时候CPU空转
        }
    }

    public override void Dispose()
    {
        base.Dispose();
        this.serviceScope.Dispose();
    }
}

事件处理

领域事件

在转码任务被创建、开始转码、转码完成等的时候,领域模型会发布领域事件,我们需要监听这个领域事件并且再次把这个事件以集成事件的形式发布出去,以便于听力后台管理程序及时的更新界面。比如EncodingItemStartedEventHandler、EncodingItemCompletedEventHandler、EncodingItemFailedEventHandler等。

EncodingItemCompletedEventHandler:把转码任务状态变化的领域事件,转换为集成事件发出(领域事件是必须的,集成事件是根据需要而定的)

class EncodingItemCompletedEventHandler : INotificationHandler<EncodingItemCompletedEvent>
{
    private readonly IEventBus eventBus;

    public EncodingItemCompletedEventHandler(IEventBus eventBus)
    {
        this.eventBus = eventBus;
    }

    public Task Handle(EncodingItemCompletedEvent notification, CancellationToken cancellationToken)
    {
        //把转码任务状态变化的领域事件,转换为集成事件发出
        eventBus.Publish("MediaEncoding.Completed", notification);
        return Task.CompletedTask;
    }
}
class EncodingItemCreatedEventHandler : INotificationHandler<EncodingItemCreatedEvent>
{
    private readonly IEventBus eventBus;

    public EncodingItemCreatedEventHandler(IEventBus eventBus)
    {
        this.eventBus = eventBus;
    }

    public Task Handle(EncodingItemCreatedEvent notification, CancellationToken cancellationToken)
    {
        return Task.CompletedTask;
    }
}
class EncodingItemFailedEventHandler : INotificationHandler<EncodingItemFailedEvent>
{
    private readonly IEventBus eventBus;

    public EncodingItemFailedEventHandler(IEventBus eventBus)
    {
        this.eventBus = eventBus;
    }
    public Task Handle(EncodingItemFailedEvent notification, CancellationToken cancellationToken)
    {
        eventBus.Publish("MediaEncoding.Failed", notification);
        return Task.CompletedTask;
    }
}
class EncodingItemStartedEventHandler : INotificationHandler<EncodingItemStartedEvent>
{
    private readonly IEventBus eventBus;

    public EncodingItemStartedEventHandler(IEventBus eventBus)
    {
        this.eventBus = eventBus;
    }
    public Task Handle(EncodingItemStartedEvent notification, CancellationToken cancellationToken)
    {
        eventBus.Publish("MediaEncoding.Started", notification);
        return Task.CompletedTask;
    }
}

集成事件

其他服务需要转码的时候,会发布一个名字为MediaEncoding.Created的集成事件。编写一个监听这个集成事件的处理器MediaEncodingCreatedHandler ,从事件携带的数据中解析出来待转码的文件路径等信息,然后把数据以EncodingItem对象的形式插入数据库即可完成转码任务的排队

[EventName("MediaEncoding.Created")]
public class MediaEncodingCreatedHandler : DynamicIntegrationEventHandler
{
    private readonly IEventBus eventBus;
    private readonly MEDbContext dbContext;

    public MediaEncodingCreatedHandler(IEventBus eventBus, MEDbContext dbContext)
    {
        this.eventBus = eventBus;
        this.dbContext = dbContext;
    }

    public override async Task HandleDynamic(string eventName, dynamic eventData)
    {
        Guid mediaId = Guid.Parse(eventData.MediaId);
        Uri mediaUrl = new Uri(eventData.MediaUrl);
        string sourceSystem = eventData.SourceSystem;
        string fileName = mediaUrl.Segments.Last();
        string outputFormat = eventData.OutputFormat;
        //保证幂等性,如果这个路径对应的操作已经存在,则直接返回
        bool exists = await dbContext.EncodingItems
            .AnyAsync(e => e.SourceUrl == mediaUrl && e.OutputFormat == outputFormat);
        if (exists)
        {
            return;
        }

        //把任务插入数据库,也可以看作是一种事件,不一定非要放到MQ中才叫事件
        //没有通过领域事件执行,因为如果一下子来很多任务,领域事件就会并发转码,而这种方式则会一个个的转码
        //直接用另一端传来的MediaId作为EncodingItem的主键
        var encodeItem = EncodingItem.Create(mediaId, fileName, mediaUrl, outputFormat, sourceSystem);
        dbContext.Add(encodeItem);
        await dbContext.SaveChangesAsync();
    }
}

文件服务配置类

public class FileServiceOptions
{
    public Uri UrlRoot { get; set; }
}

DbContextFactory

//用IDesignTimeDbContextFactory坑最少,最省事
public class DesignTimeDbContextFactory : IDesignTimeDbContextFactory<MEDbContext>
{
    public MEDbContext CreateDbContext(string[] args)
    {
        var optionsBuilder = DbContextOptionsBuilderFactory.Create<MEDbContext>();
        return new MEDbContext(optionsBuilder.Options, null);
    }
}

服务和管道模型

从数据库读取配置

builder.Services.Configure<FileServiceOptions>(builder.Configuration.GetSection("FileService:Endpoint"));
builder.Services.Configure<JWTOptions>(builder.Configuration.GetSection("JWT"));

AddHttpClient:这个是干啥的暂时未知

builder.Services.AddHttpClient();

注册转码后台服务

builder.Services.AddHostedService<EncodingBgService>();//后台转码服务

完整代码

var builder = WebApplication.CreateBuilder(args);

builder.ConfigureDbConfiguration();
builder.ConfigureExtraServices(new InitializerOptions
{
    LogFilePath = "e:/temp/MediaEncoder.log",
    EventBusQueueName = "MediaEncoder.WebAPI"
});
builder.Services.Configure<FileServiceOptions>(builder.Configuration.GetSection("FileService:Endpoint"));
builder.Services.Configure<JWTOptions>(builder.Configuration.GetSection("JWT"));
builder.Services.AddHttpClient();
builder.Services.AddHostedService<EncodingBgService>();//后台转码服务
builder.Services.AddControllers();
builder.Services.AddSwaggerGen(c =>
{
    c.SwaggerDoc("v1", new() { Title = "MediaEncoder.WebAPI", Version = "v1" });
});

var app = builder.Build();

// Configure the HTTP request pipeline.
if (builder.Environment.IsDevelopment())
{
    app.UseDeveloperExceptionPage();
    app.UseSwagger();
    app.UseSwaggerUI(c => c.SwaggerEndpoint("/swagger/v1/swagger.json", "MediaEncoder.WebAPI v1"));
}
app.UseZackDefault();
app.MapControllers();
app.Run();